Módulo 5: Data Fetching With React Query
Stale-while-revalidate: `staleTime` y `gcTime`
Descripción
Esta lección explica el comportamiento por defecto de React Query, el que le da su sensación de instantaneidad y el más importante de entender: stale-while-revalidate. La idea, en una frase: cuando pides un dato que ya está en la caché pero envejeció, la librería te da la copia cacheada al instante (para que la UI no muestre un spinner) y, en paralelo, refetchea en background para conseguir la versión fresca y reemplazarla cuando llegue. "Stale" (sirve lo viejo) "while" (mientras) "revalidate" (revalida): muestra lo que tiene ya, y revalida por debajo.
El interruptor que gobierna todo esto es staleTime: cuánto tiempo la librería considera fresco un dato tras recibirlo. Dentro de staleTime, el dato se considera fresco → se sirve del caché sin ir a la red (cero fetches). Pasado staleTime, el dato se considera stale → la librería hace stale-while-revalidate (sirve el caché viejo y refetchea en background). Por defecto staleTime es 0, así que el dato se considera stale de inmediato: en cuanto un componente vuelve a montar (o la ventana recupera el foco), se revalida —siempre mostrando primero lo cacheado—. Subir staleTime (por ejemplo a 60 segundos para un catálogo que cambia poco) reduce revalidaciones. Y hay un segundo tiempo, gcTime (garbage collection time, antes llamado cacheTime): cuánto sobrevive una entrada inactiva (sin ningún componente usándola) antes de que la librería la borre de memoria; por defecto 5 minutos.
Conexión con el módulo. La lección 4 cubrió el "cuánto" (un fetch para N componentes); esta cubre el "cuándo" (cuándo se sirve el caché y cuándo se revalida). Es la respuesta a dos males de M4: la copia stale para siempre (useEffect(fetch, []) nunca refetcheaba → aquí la política de revalidación lo hace) y el spinner en cada recarga (aquí no hay spinner: se muestra lo cacheado mientras se revalida). También materializa la "política de revalidación" que M4 pidió: staleTime es esa política, hecha una opción. Es el corazón declarativo del "cuándo": no orquestas cuándo refetchar, declaras cuánto dura fresco el dato.
Una analogía: la edición nueva que la bibliotecaria revisa
Vuelve a la biblioteca con recibos, y quédate con la escena que define este módulo. Pides un libro. La bibliotecaria mira el estante y ya tiene un ejemplar. Aquí es donde una biblioteca mala y una buena se separan.
Una biblioteca mala te diría: "espera, voy a llamar a la editorial para confirmar que esta es la última edición" —y te deja parado en el mostrador diez minutos mientras verifica—. Eso es el spinner en cada recarga: aunque el libro está en el estante, te hace esperar la verificación.
La biblioteca buena hace otra cosa. Te entrega el ejemplar del estante en la mano, ya, y te dice: "toma, este es; mientras lo hojeas, reviso con la editorial si salió una edición nueva, y si hay, te la cambio". Te vas a leer de inmediato con la copia que había, y la revisión ocurre por detrás. Si no había edición nueva, te quedas con la que tienes (no perdiste tiempo). Si había una, la bibliotecaria te la trae y reemplaza la vieja sin drama. Eso es stale-while-revalidate: copia en mano al instante, revisión en background.
¿Y cuándo se molesta en revisar? Ahí entra staleTime. Si le acabas de pedir el libro hace un minuto, la bibliotecaria no vuelve a llamar a la editorial —confía en que en un minuto no salió una edición nueva— (dentro de staleTime, no revalida). Si pasó una hora, sí revisa (pasado staleTime, revalida). staleTime es cuánto tiempo confía en su copia antes de molestarse en verificar. Y gcTime es cuánto tiempo guarda un ejemplar que nadie está leyendo antes de devolverlo al almacén para hacer espacio.
Ejemplo trabajado: la lectura fresca (sin fetch) y la stale (cache + refetch)
Modelamos en Node las dos rutas: leer dentro de staleTime (fresco, sin fetch) y leer pasado staleTime (stale, cache al instante + refetch en background). Primero, cómo se declara en React —el staleTime es una opción de useQuery—:
function ProductList() {
const { data, isFetching } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 60_000, // el catalogo se considera fresco por 60s (no revalida en ese lapso)
});
// data se muestra SIEMPRE que exista, este fresco o revalidandose.
// isFetching indica si hay un refetch en background (para un indicador sutil, opcional).
return (
<div>
{isFetching && <RefreshDot />} {/* opcional: un puntito de "actualizando" */}
<ul>{data?.map((p) => <ProductCard key={p.id} product={p} />)}</ul>
</div>
);
}
Con staleTime: 60_000, dentro de 60s la librería sirve el caché sin pedir; pasados, revalida en background. Ejecutemos la mecánica con un reloj manual:
const backend = {
calls: 0,
_products: [
{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599 },
{ id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
],
fetchProducts() { this.calls++; return this._products.map((p) => ({ ...p })); },
};
const priceOf = (list, id) => '$' + (list.find((p) => p.id === id).priceCents / 100).toFixed(2);
const clock = { now: 0 };
function createQueryClient() {
const cache = new Map();
const inflight = new Set();
const queue = [];
const hash = (key) => JSON.stringify(key);
function useQuery(queryKey, queryFn, { staleTime = 0 } = {}) {
const h = hash(queryKey);
const entry = cache.get(h);
const hasData = !!entry && entry.status === 'success';
const errored = !!entry && entry.status === 'error';
const isStale = !entry || clock.now - entry.updatedAt >= staleTime; // fresco mientras la edad < staleTime
if ((!hasData || isStale) && !errored && !inflight.has(h)) {
inflight.add(h);
queue.push({ h, queryFn });
}
return {
data: entry ? entry.data : undefined,
isLoading: !hasData && inflight.has(h),
isFetching: inflight.has(h),
isError: errored,
};
}
function flush() {
const batch = queue.splice(0);
batch.forEach(({ h, queryFn }) => {
try { cache.set(h, { data: queryFn(), updatedAt: clock.now, status: 'success' }); }
catch (err) { const prev = cache.get(h) || {}; cache.set(h, { data: prev.data, updatedAt: clock.now, status: 'error' }); }
inflight.delete(h);
});
return batch.length;
}
return { useQuery, flush, cache, hash };
}
const q = ['products'];
const fn = () => backend.fetchProducts();
const opts = { staleTime: 5000 }; // el dato se considera FRESCO durante 5s
console.log('=== Stale-while-revalidate: cache al instante + refetch en background ===\n');
const qc = createQueryClient();
backend.calls = 0; clock.now = 0;
// Montaje inicial: no hay dato -> loading -> llega la respuesta ------------
console.log('Montaje inicial (clock=0): useQuery(["products"], staleTime=5000)');
const mount = qc.useQuery(q, fn, opts);
console.log(' render loading -> data=' + mount.data + ' | isFetching=' + mount.isFetching);
qc.flush();
console.log(' flush -> data=' + priceOf(qc.cache.get(qc.hash(q)).data, 'p1') + ' guardado con updatedAt=0\n');
// 1) DENTRO de staleTime: fresco, no revalida ------------------------------
backend.calls = 0; clock.now = 3000;
console.log('1) DENTRO de staleTime (clock=3000, edad=3000 < 5000): el dato esta FRESCO');
const fresh = qc.useQuery(q, fn, opts);
console.log(' lectura -> data=' + priceOf(fresh.data, 'p1') + ' al instante | isFetching=' + fresh.isFetching + ' | fetches=' + backend.calls);
console.log(' (no revalida: dentro de staleTime el cache se sirve sin ir a la red)\n');
// El backend cambia por debajo -------------------------------------------
backend._products[0].priceCents = 1999;
console.log('>>> el backend baja el precio del Mouse a $19.99 (otro usuario) <<<\n');
// 2) PASADO staleTime: stale-while-revalidate ------------------------------
backend.calls = 0; clock.now = 8000;
console.log('2) PASADO staleTime (clock=8000, edad=8000 >= 5000): STALE -> stale-while-revalidate');
const stale = qc.useQuery(q, fn, opts);
console.log(' render 1 (al instante): data=' + priceOf(stale.data, 'p1') + ' (cache viejo) | isFetching=' + stale.isFetching + ' (refetch en background)');
console.log(' ...llega la respuesta del background...');
qc.flush();
const revalidated = qc.useQuery(q, fn, opts);
console.log(' render 2 (revalidado): data=' + priceOf(revalidated.data, 'p1') + ' (fresco) | isFetching=' + revalidated.isFetching);
console.log(' fetches en la revalidacion -> ' + backend.calls + ' (uno, en background; la UI nunca mostro spinner)');
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== Stale-while-revalidate: cache al instante + refetch en background ===
Montaje inicial (clock=0): useQuery(["products"], staleTime=5000)
render loading -> data=undefined | isFetching=true
flush -> data=$25.99 guardado con updatedAt=0
1) DENTRO de staleTime (clock=3000, edad=3000 < 5000): el dato esta FRESCO
lectura -> data=$25.99 al instante | isFetching=false | fetches=0
(no revalida: dentro de staleTime el cache se sirve sin ir a la red)
>>> el backend baja el precio del Mouse a $19.99 (otro usuario) <<<
2) PASADO staleTime (clock=8000, edad=8000 >= 5000): STALE -> stale-while-revalidate
render 1 (al instante): data=$25.99 (cache viejo) | isFetching=true (refetch en background)
...llega la respuesta del background...
render 2 (revalidado): data=$19.99 (fresco) | isFetching=false
fetches en la revalidacion -> 1 (uno, en background; la UI nunca mostro spinner)
Lee la corrida como las dos rutas de una lectura.
Montaje inicial. No había dato, así que el primer render fue data=undefined | isFetching=true (carga real: la única vez que la UI muestra un spinner). Cuando llegó la respuesta, el dato se guardó con updatedAt=0 —la marca de tiempo de cuándo se recibió, que es lo que staleTime usa para medir la edad—.
Ruta 1 — dentro de staleTime: fresco, sin fetch. A los 3 segundos (edad 3000 < 5000), el dato se considera fresco. La lectura devolvió data=$25.99 al instante, isFetching=false, y fetches=0. La librería no fue a la red: confió en su copia porque aún está dentro de la ventana fresca. Esto es el ahorro puro de staleTime: mientras el dato esté fresco, releerlo cuesta cero peticiones. Es la bibliotecaria que no vuelve a llamar a la editorial si le pediste el libro hace un minuto.
Ruta 2 — pasado staleTime: stale-while-revalidate. Entre medias, el backend bajó el precio a $19.99 y el reloj avanzó a 8 segundos (edad 8000 ≥ 5000), así que el dato quedó stale. Aquí ocurre el comportamiento estrella, en dos renders:
- Render 1:
data=$25.99 (cache viejo) | isFetching=true. La librería sirvió el caché al instante —aunque esté viejo— para no dejar la UI en blanco, y en paralelo disparó un refetch (por esoisFetching=true). El usuario ve el precio viejo, sí, pero lo ve ya, sin spinner. - Render 2:
data=$19.99 (fresco) | isFetching=false. El refetch de background volvió con el precio nuevo, la caché se actualizó, y el componente re-renderizó con$19.99. El puntito de "actualizando" se apaga.
fetches en la revalidacion -> 1: una sola petición, en background. La UI nunca mostró un spinner: mostró el dato viejo y lo reemplazó por el nuevo. Ese es el sello de React Query, y por qué las apps que lo usan se sienten instantáneas: casi nunca ves una pantalla de carga tras la primera vez, porque siempre hay una copia que mostrar mientras se revalida.
Profundización: staleTime, gcTime, y cuándo revalida
staleTime — cuánto dura fresco (por defecto 0). Es el único interruptor que decide "fresco vs stale". Con staleTime: 0 (el defecto), el dato se marca stale apenas llega, así que cualquier disparador de revalidación (remontar, volver a la ventana) refetchea —siempre mostrando lo cacheado primero—. Subirlo (staleTime: 60_000) le dice a la librería "confía en esta copia 60 segundos": durante ese lapso, cero revalidaciones. Elige staleTime según cuán rápido cambia el dato: alto para cosas que cambian poco (el catálogo, el perfil), bajo o 0 para cosas volátiles (un stock en venta flash, un feed en vivo).
gcTime — cuánto sobrevive sin uso (por defecto 5 min). Es distinto de staleTime. staleTime decide si un dato activo (en uso) se revalida; gcTime decide cuánto tiempo una entrada inactiva (ningún componente la usa —todos los que la pedían se desmontaron—) permanece en memoria antes de que la librería la borre. Por defecto, 5 minutos: si navegas fuera de una página y vuelves antes de 5 minutos, el dato sigue en caché (cache hit instantáneo); si vuelves después, ya fue recolectado y se pide de nuevo. gcTime gestiona la memoria; staleTime, la frescura.
Cuándo revalida React Query (los disparadores). Cuando un dato está stale, la librería lo revalida en varios momentos, todos activados por defecto (los que M4 pidió como "política"): al montar un componente que usa la query, al volver el foco a la ventana (refetchOnWindowFocus, cambias de pestaña y regresas), y al reconectar la red (refetchOnReconnect). Cada uno es un "vuelvo a mirar el estante" que dispara una revisión si el dato ya envejeció. Puedes desactivarlos por query, pero por defecto están, y es lo que mantiene la UI fresca sin que orquestes nada.
Por qué stale-while-revalidate es el modelo correcto para el servidor. El estado del servidor siempre puede estar un poco desactualizado (la verdad es de otro, M4). Fingir lo contrario —bloquear la UI hasta confirmar— es lento y casi siempre innecesario (la mayoría de las veces el dato no cambió). Stale-while-revalidate acepta la realidad: muestra la mejor copia que tienes ya, y corrige por detrás si hace falta. Es rápido en el caso común (el dato no cambió) y correcto en el raro (cambió → se actualiza solo). Optimiza para la percepción del usuario sin sacrificar la exactitud.
Errores comunes
Poner staleTime: 0 (o dejarlo) y sorprenderse de los refetches. Qué pasa: la app refetchea "todo el tiempo" —al cambiar de pestaña, al remontar— y parece excesivo. Por qué pasa: staleTime por defecto es 0, así que todo dato está stale de inmediato y cualquier disparador revalida. Cómo detectarlo: ves peticiones en la pestaña de red cada vez que vuelves a la ventana. Cómo corregirlo: no es un bug —siempre muestra lo cacheado primero, sin spinner—, pero si el dato cambia poco, sube staleTime (60_000, 5 * 60_000) para revalidar menos. La regla: staleTime alto para datos estables, bajo para volátiles.
Confundir staleTime con gcTime. Qué pasa: se sube gcTime esperando menos refetches, o se baja staleTime esperando liberar memoria. Por qué pasa: los dos son "tiempos de caché" y se mezclan. Cómo detectarlo: cambias uno y no pasa lo esperado. Cómo corregirlo: staleTime = cuánto dura fresco (controla revalidaciones de datos en uso); gcTime = cuánto vive una entrada inactiva en memoria (controla cuándo se borra lo que nadie usa). Para menos refetches, sube staleTime. Para retener datos en caché al navegar de ida y vuelta, sube gcTime.
Creer que "muestra el dato viejo" es un bug. Qué pasa: se ve que el render 1 mostró el precio viejo antes del nuevo y se piensa "React Query me dio datos incorrectos". Por qué pasa: se espera que un fetch bloquee hasta tener lo último. Cómo detectarlo: reportas como bug el instante en que se ve la copia cacheada durante una revalidación. Cómo corregirlo: es el diseño, y es correcto —stale-while-revalidate prioriza mostrar algo al instante sobre mostrar lo último con espera—. El dato viejo se reemplaza por el fresco en milisegundos, sin spinner. Si un dato no puede mostrarse viejo ni un instante (un saldo bancario en una transferencia), esa query necesita staleTime: 0 y quizás bloquear con isFetching; pero para la enorme mayoría (un catálogo, un perfil), mostrar lo cacheado mientras se revalida es exactamente lo que quieres.
Ejercicios
Ejercicio 1 — Predice los fetches. Una query useQuery({ queryKey: ['products'], queryFn, staleTime: 10_000 }) se monta y recibe el dato en el segundo 0. Luego se relee en estos momentos: (a) segundo 4; (b) segundo 9; (c) segundo 12. Para cada uno, di si va a la red y qué muestra.
Ver solución
El dato se guardó en el segundo 0 con staleTime: 10_000 (fresco hasta el segundo 10).
- (a) segundo 4: edad 4 < 10 → fresco. No va a la red (
fetches=0); muestra el caché al instante. - (b) segundo 9: edad 9 < 10 → fresco. No va a la red; muestra el caché.
- (c) segundo 12: edad 12 ≥ 10 → stale. Hace stale-while-revalidate: muestra el caché viejo al instante y refetchea en background (1 fetch). Cuando el refetch vuelve, muestra el dato fresco.
Dentro de la ventana (a, b): cero fetches. Fuera (c): cache al instante + un refetch. Siempre muestra algo sin spinner.
Ejercicio 2 — Elige el staleTime. Para cada dato de Mercado, propón un staleTime razonable y justifícalo: (a) el catálogo de productos (cambia unas pocas veces al día); (b) el stock de un producto en una venta flash (cambia cada segundo); (c) el perfil del usuario logueado (cambia rara vez, cuando él lo edita).
Ver solución
- (a) catálogo →
staleTimealto, p. ej.5 * 60_000(5 min) o más. Cambia poco, así que no vale la pena revalidarlo a cada rato; una copia de varios minutos es más que suficiente, y ahorra peticiones. - (b) stock en venta flash →
staleTime: 0(o muy bajo,1000). Cambia constantemente y mostrar un número viejo puede llevar a comprar algo agotado; quieres revalidar agresivamente. (Incluso podrías usarrefetchIntervalpara revalidar cada X segundos, aunque eso es una opción aparte.) - (c) perfil del usuario →
staleTimealto, p. ej.5 * 60_000. Solo cambia cuando el propio usuario lo edita —y en ese momento invalidas la query tú mismo (M6)—, así que entre ediciones puede considerarse fresco largo rato.
La regla: staleTime proporcional a cuán rápido cambia la verdad en el backend y a cuánto importa mostrarla exacta.
Ejercicio 3 — Explica "sin spinner". Un compañero dice: "no entiendo cómo React Query actualiza el precio sin mostrar un spinner de carga como hacía nuestro useEffect". Explícaselo con la analogía de la biblioteca y los dos renders de la corrida.
Ver solución
Con el useEffect viejo, cuando el dato tenía que actualizarse, borrabas lo que había (setData(null)), mostrabas un spinner, pedías, y al volver mostrabas lo nuevo. El usuario veía: dato → spinner → dato nuevo. Un parpadeo en cada actualización.
React Query no borra lo que tiene. Cuando el dato está stale y hay que revalidar, hace dos renders: en el render 1 te da la copia que ya tenía (el precio viejo, $25.99) al instante, y arranca el refetch por detrás (isFetching=true); en el render 2, cuando el refetch vuelve, reemplaza por el precio fresco ($19.99). El usuario ve: dato viejo → dato nuevo, sin spinner en medio, porque siempre hubo una copia en pantalla.
Es la bibliotecaria que te da el ejemplar del estante en la mano y revisa la edición nueva mientras lo hojeas, en vez de dejarte parado en el mostrador mientras llama a la editorial. Tienes algo que leer desde el primer segundo; si hay algo mejor, te lo cambian sin interrumpirte. Eso es stale-while-revalidate, y es por lo que las apps con React Query se sienten rápidas.
Resumen y siguiente paso
En esta lección entendiste el comportamiento estrella de React Query: stale-while-revalidate. Cuando un dato cacheado envejece, la librería sirve la copia vieja al instante (sin spinner) y refetchea en background para reemplazarla por la fresca. El interruptor es staleTime: dentro de la ventana, el dato es fresco → cache sin fetch; pasada, es stale → cache al instante + refetch. Lo anclaste con la bibliotecaria que te da el ejemplar en la mano mientras revisa si hay edición nueva, y lo mediste ejecutando las dos rutas: dentro de staleTime (segundo 3), fetches=0 y $25.99 al instante; pasado (segundo 8), $25.99 viejo en el render 1 (isFetching=true) y $19.99 fresco en el render 2, con un solo fetch y sin spinner. Y distinguiste gcTime (cuánto vive una entrada inactiva en memoria, 5 min por defecto) de staleTime (frescura).
Antes de avanzar deberías poder: explicar stale-while-revalidate en tus palabras; predecir si una lectura va a la red según su edad y staleTime; distinguir staleTime de gcTime; y elegir un staleTime razonable según cuán rápido cambia un dato.
La lección 6 afina los estados que has visto de reojo. Vas a ver, ejecutado, la diferencia clave entre isLoading (primera carga, sin dato) e isFetching (cualquier fetch, incluido el de background que acabas de ver), más isError y data, a lo largo de la vida completa de una query. Es lo que necesitas para renderizar cada estado correctamente —el spinner solo la primera vez, el puntito de "actualizando" en las revalidaciones, el "Reintentar" en los errores—.
Recursos
- TanStack Query, "Important Defaults" — tanstack.com/query/latest/docs/framework/react/guides/important-defaults. La doc que explica
staleTime: 0por defecto, stale-while-revalidate, y los disparadores de revalidación (foco, reconexión, montaje). La referencia exacta de esta lección. En inglés. - TanStack Query, "Caching" — tanstack.com/query/latest/docs/framework/react/guides/caching. El ciclo de vida de una entrada: fresh → stale → inactive → garbage collected, con
staleTimeygcTime. En inglés. - TkDodo, "Practical React Query" (sección sobre
staleTime) — tkdodo.eu/blog/practical-react-query#the-defaults-explained. Por quéstaleTimees la opción que más vale la pena ajustar, con ejemplos. En inglés. - TkDodo, "React Query as a State Manager" — tkdodo.eu/blog/react-query-as-a-state-manager. Por qué stale-while-revalidate es el modelo correcto para el estado del servidor, frente al fetch único que bloquea. En inglés.