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.
El reset que recibe el fallback de un ErrorBoundary promete recuperación, pero por sí solo no arregla nada: vuelve a montar los hijos y, si la causa persiste, el error reaparece al instante. Para que un botón de “reintentar” recargue de verdad, hay que entender una sutileza que casi nadie articula: dónde se creó el recurso decide si reset basta o si necesitas dispararlo con un refetch. De esa distinción depende que tu reintento funcione o gire en el vacío.
- Comprender que
resetre-monta los hijos pero no cambia por sí mismo la causa del fallo. - Determinar cuándo
resetbasta según dónde se creó el recurso. - Construir un reintento con
refetchseguido deresetcuando el recurso vive fuera del límite. - Adaptar el reintento con el argumento de
refetchy la banderainfo.refetching.
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 recupera
function 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 solo
function 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.
flowchart TD
FAIL[recurso en errored, fallback visible] --> DEC{donde se creo el recurso}
DEC -->|dentro del limite| IN[reset re-monta y re-crea el recurso]
IN --> R1[el fetcher corre de nuevo solo]
DEC -->|fuera del limite| OUT[reset re-monta pero el recurso sigue roto]
OUT --> NEED[hace falta refetch antes de reset]
NEED --> R2[refetch limpia el error y recarga]
style IN fill:#a6e3a1,color:#11111b
style NEED fill:#f9e2af,color:#11111bEl 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.
reset re-monta
Descarta el subarbol de error y vuelve a montar los hijos; no toca por si mismo el estado de ningun recurso.
Donde vive decide
Recurso dentro del limite: reset lo re-crea y recarga. Recurso fuera: sobrevive roto y hace falta refetch.
Reintento distinto
info.refetching lleva el argumento de refetch para saltar cache o cambiar la peticion del reintento.
La función fallback corre dentro del owner del ErrorBoundary, así que puede crear su propia reactividad. Nada te obliga a esperar un clic: un createEffect con un setTimeout puede llamar a refetch y reset sola tras un retraso, contando los intentos en un signal que sobrevive a los reset. El botón de “reintentar” y la recuperación automática son el mismo mecanismo disparado por manos distintas —el usuario o un temporizador—.
El malentendido de raíz sobre reset es tratarlo como un botón mágico de reparación cuando es apenas la mitad del mecanismo: el reset re-ejecuta la lectura, pero recuperarse exige que la realidad que la lectura observa haya cambiado entre el fallo y el reintento. Ahí es donde la topología se vuelve destino. Si el recurso nace dentro de los hijos del límite, re-montarlos lo re-crea con estado virgen y la nueva instancia dispara su fetcher: el cambio de realidad viene gratis con el reset. Si el recurso nace fuera —el caso dominante, porque solemos declararlo junto al límite que lo envuelve, en el owner del padre—, sobrevive intacto al reset con su error a cuestas, y entonces reset sin refetch es un bucle: re-lees lo mismo roto y vuelves al fallback en el mismo tick. La cura es separar las dos responsabilidades y ordenarlas: refetch cambia el mundo —deja el estado errored, lanza una petición nueva, y con info.refetching puede incluso lanzarla diferente—, y solo después reset re-intenta la lectura sobre ese mundo ya distinto. Cuando interiorizas que el reintento no vive en el límite sino en el recurso, y que el límite solo vuelve a mirar, dejas de escribir botones que fingen recuperar y empiezas a diseñar recuperaciones que de verdad tocan la fuente del fallo antes de volver a leerla.
- Declara el recurso en el cuerpo del componente que monta el
ErrorBoundary, provoca un fallo y comprueba quereseta secas te devuelve alfallbacksin recargar. - Añade
refetch()antes delreset()y verifica que ahora la recuperación pasa por un esqueleto deSuspensey llega a datos frescos. - Mueve la creación del recurso a un hijo montado dentro del límite y confirma que ahora
reseta secas sí vuelve a disparar el fetch. - Usa
info.refetchingpara que el reintento salte la caché concache: "no-store"y observa que la carga inicial sí la usó. - Añade una guarda que ignore el clic de reintentar mientras
resource.loadingvalga verdadero y comprueba que ya no se encadenan peticiones.