reset y reintento: volver a disparar el fetch con dignidad
El reset de ErrorBoundary re-monta los hijos pero no repara nada por si mismo. La pieza que casi nadie ve es que donde se crea el recurso decide si reset basta: creado dentro del limite, reset lo re-crea y el fetcher corre solo; creado fuera, el recurso sigue roto y hace falta un refetch explicito antes del reset. Como construir un boton reintentar que vuelva a disparar la peticion, por que el orden refetch y luego reset importa, y como usar el argumento de refetch e info.refetching para que el reintento sea distinto del intento original.
reset re-monta, no repara
reset hace una sola cosa: descarta el subárbol de error y vuelve a montar los hijos del límite desde cero. No toca el estado de ningún recurso, no reintenta ninguna petición, no limpia ninguna caché. Si el render que falló vuelve a ejecutarse sobre las mismas condiciones —el recurso sigue en errored, el dato sigue siendo inválido—, la lectura vuelve a lanzar y regresas al fallback en el mismo tick. reset solo recupera cuando algo cambió entre el fallo y el reintento.
El caso de libro es un fallo transitorio: una operación que falla las primeras veces y luego funciona. Ahí reset brilla, porque la condición que rompía desaparece sola entre un intento y el siguiente.
let intentos = 0;async function cargarConRedInestable() { if (intentos++ < 2) throw new Error("fallo transitorio"); // falla dos veces return await pedirDatos(); // a la tercera, va}La pregunta operativa, entonces, es: ¿qué tiene que cambiar, y quién lo cambia? La respuesta depende enteramente de un detalle topológico que suele pasarse por alto.
Dónde vive el recurso decide si reset basta
Un recurso creado dentro de los hijos del límite es re-creado cuando reset los re-monta: nace un createResource nuevo, con estado fresco, y su fetcher corre otra vez. En ese caso reset es el reintento. Pero un recurso creado fuera del límite —en el componente padre, por encima del ErrorBoundary— sobrevive al reset: sigue siendo la misma instancia, todavía en errored, así que la lectura re-montada vuelve a lanzar de inmediato.
// recurso FUERA del limite: reset solo no recuperafunction Panel() { const [datos] = createResource(fuente, cargar); // vive en el owner del padre return ( <ErrorBoundary fallback={(err, reset) => <button onClick={reset}>Reintentar</button>}> <Vista datos={datos()} /> {/* reset re-monta esto, pero datos sigue roto */} </ErrorBoundary> );}// recurso DENTRO del limite: reset lo re-crea y el fetcher corre solofunction Panel() { return ( <ErrorBoundary fallback={(err, reset) => <button onClick={reset}>Reintentar</button>}> <Cargador /> {/* Cargador crea el recurso en su cuerpo: reset lo renace */} </ErrorBoundary> );}El caso más común en el código real es el primero: el recurso se declara en el cuerpo del componente que también monta el ErrorBoundary, de modo que vive en el owner del padre del límite, no en sus hijos. Por eso tantos botones de “reintentar” parecen no hacer nada: hacen reset, pero el recurso al que apuntan no se enteró.
De aquí sale una recomendación de arquitectura: si quieres que reset sea el reintento —sin coordinar refetch—, empuja la creación del recurso hacia dentro del límite, a un componente Cargador que se monte como hijo. Ese pequeño desplazamiento topológico convierte el reset en una recuperación completa, porque re-montar al hijo es re-crear su recurso. Cuando no puedes moverlo —porque el recurso lo comparten varios consumidores por encima del límite— aceptas el modelo de dos pasos: refetch para cambiar la realidad, reset para volver a leerla.
El botón reintentar: refetch y luego reset
Cuando el recurso vive fuera del límite, el reintento correcto combina dos acciones en orden. Primero refetch, que limpia el error del recurso y lanza una nueva carga; después reset, que re-monta los hijos para que su lectura vuelva a intentarse —ahora sobre un recurso que está cargando, no roto—.
const [datos, { refetch }] = createResource(fuente, cargar);<ErrorBoundary fallback={(err, reset) => ( <Fallo mensaje={err.message} onReintentar={() => { refetch(); // 1: nueva peticion, el recurso deja errored y pasa a refreshing reset(); // 2: re-monta los hijos para releer el recurso ya en recuperacion }} /> )}> <Suspense fallback={<Esqueleto />}> <Vista datos={datos()} /> </Suspense></ErrorBoundary>;El orden importa: si invirtieras las llamadas, reset re-montaría los hijos leyendo un recurso aún en errored y volverías al fallback antes de que refetch tuviera efecto visible. Con refetch primero, el Suspense interior toma el relevo mientras la nueva petición está en vuelo, y el usuario ve un esqueleto en lugar de un parpadeo de error. La recuperación se siente como una carga normal, que es exactamente lo que es.
Reintentar distinto del intento original
Un reintento ciego repite la misma petición que ya falló. A menudo quieres que el segundo intento sea distinto: saltar una caché, añadir una cabecera, pedir menos datos. refetch acepta un argumento que llega al fetcher como info.refetching, y el fetcher puede ramificar sobre él.
const [datos, { refetch }] = createResource(fuente, async (id, info) => { const forzar = info.refetching === "sin-cache"; // valor pasado a refetch const url = forzar ? `/api/x/${id}?fresh=1` : `/api/x/${id}`; const res = await fetch(url, { cache: forzar ? "no-store" : "default" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json();});// en el fallback: reintenta saltando la cache<button onClick={() => { refetch("sin-cache"); reset(); }}>Reintentar sin caché</button>;info.refetching vale false en la carga inicial, true en un refetch() sin argumento, o el valor que le pases. Es el canal para que un reintento no sea una repetición sino una estrategia: el primer intento confía en la caché, el reintento la evita; el primero pide la página entera, el reintento pide solo lo esencial. Y si un reintento manual puede dispararse muchas veces seguidas, protege la recarga con una guarda —ignorar clics mientras resource.loading esté activo— para no encadenar peticiones que se pisan.
Un apunte de corrección: reintentar asume que la operación es idempotente. Releer un recurso con GET lo es —pedirlo dos veces no cambia nada—, pero si tu fetcher esconde un efecto secundario, como un POST que crea un registro, un reintento automático puede duplicarlo. Reserva el reintento transparente para lecturas; para escrituras, exige una confirmación explícita del usuario antes de repetir.