Módulo 5: Data Fetching With React Query

La caché y el dedup: N componentes, un fetch

Descripción

En la lección 3 estableciste que la queryKey es la identidad del dato. Esta lección cosecha el primer beneficio de esa identidad: una copia por queryKey, compartida por todos los componentes que la declaran. De ahí salen dos cosas que en el módulo 4 tenías que resolver a mano y que ahora vienen gratis: la caché (si el dato ya volvió, se sirve del estante sin ir a la red) y el dedup (si dos componentes piden el mismo dato a la vez, antes de que ninguna respuesta llegue, comparten una sola petición en vez de disparar dos).

La distinción entre las dos es fina pero importante, y es la misma que M4 midió. La caché cubre el caso "el dato ya volvió": un componente que pide ['products'] después de que otro ya lo trajo lee la copia guardada, cero fetches. El dedup cubre el caso "el dato aún no vuelve": dos (o tres) componentes que montan al mismo tiempo y declaran ['products'] antes de que llegue ninguna respuesta —aquí la caché todavía no ayuda (no hay nada guardado), pero la capa ve que esa llave ya está en vuelo y hace que las monturas siguientes esperen esa misma petición—. Caché = "ya está en el estante"; dedup = "ya lo estoy pidiendo, espera". Las dos se apoyan en la misma llave, y las dos las hace React Query por defecto.

Conexión con el módulo. La lección 4 del módulo 4 midió estos mismos dos males —sin caché (3 componentes → 3 fetches) y sin dedup (2 monturas → 2 requests)— y modeló su cura conceptual. Esta lección muestra esa cura ya hecha por la herramienta: declaras la misma queryKey y obtienes caché y dedup sin escribir nada. Es la aplicación directa de la identidad de la lección 3: como la llave coordina, N consumidores del mismo dato generan una petición y una copia. Con esto queda claro el "cuánto" (número de fetches); la lección 5 aborda el "cuándo" (stale-while-revalidate).

Una analogía: el pizarrón "ya se pidió" de la cocina

Vuelve a la cocina del restaurante. Cuando un mesero necesita un plato, no le grita directo al cocinero: lo anota en el pizarrón —"mesa 4, pasta"— y el cocinero trabaja del pizarrón. Ese pizarrón es la caché por queryKey, y de él salen las dos ideas de la lección.

Dedup — mientras el plato se cocina. Imagina que tres meseros, casi al mismo tiempo, necesitan la misma pasta para tres mesas. El primero la anota: "pasta — pedido". El segundo, antes de anotar, mira el pizarrón, ve que la pasta ya está pedida y en preparación, y no la vuelve a anotar: se queda esperando ese mismo plato. El tercero, igual. Una anotación, un plato en el fuego, tres meseros esperándolo. Si no miraran el pizarrón, gritarían "pasta" tres veces y el cocinero haría tres pastas. El pizarrón —la llave compartida— evita las peticiones duplicadas en vuelo.

Caché — cuando el plato ya está listo. Ahora imagina que la pasta ya salió y hay un plato de más en la barra (porque una mesa lo canceló). Llega un cuarto mesero que necesita pasta: mira la barra, ve que ya hay una lista, y la toma —sin pedirle nada al cocinero—. Eso es el cache hit: el dato ya volvió, se sirve del estante, cero trabajo para la cocina. La diferencia con el dedup es el momento: el dedup pasa mientras el plato se cocina (nadie lo tiene aún, pero ya está en marcha); el cache hit pasa cuando el plato ya está listo (se sirve directo). En los dos, el pizarrón —la queryKey— es lo que coordina.

Ejemplo trabajado: 3 en vuelo → 1 fetch, y el 4º → 0

Modelamos en Node los dos casos: tres componentes que declaran ['products'] a la vez (dedup) y un cuarto que pide después (caché). Primero, cómo se ve en React —tres componentes distintos, la misma declaración—:

// Tres componentes DISTINTOS que muestran los mismos productos.
// Los tres declaran la MISMA queryKey -> una copia, un fetch (la capa lo coordina).
function ProductList()   { const { data } = useQuery({ queryKey: ['products'], queryFn: fetchProducts }); /* ... */ }
function FeaturedGrid()  { const { data } = useQuery({ queryKey: ['products'], queryFn: fetchProducts }); /* ... */ }
function SearchResults() { const { data } = useQuery({ queryKey: ['products'], queryFn: fetchProducts }); /* ... */ }
// Comparado con M4: alli cada uno tenia su useEffect -> 3 fetches. Aqui: 1.

Los tres declaran ['products']; la capa los coordina. Ejecutemos los contadores:

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;
    if ((!hasData || isStale) && !errored && !inflight.has(h)) {  // dedup: si la key ya esta en vuelo, no encola otra
      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 FRESH = { staleTime: 5000 };

console.log('=== El cache y el dedup: N componentes, 1 fetch ===\n');

const qc = createQueryClient();
backend.calls = 0; clock.now = 0;

// 1) DEDUP: peticion EN VUELO compartida ----------------------------------
console.log('1) DEDUP — 3 componentes usan useQuery(["products"]) antes de la respuesta:');
const list = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);  // ProductList  (dispara)
const grid = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);  // FeaturedGrid (en vuelo -> reusa)
const srch = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);  // SearchResults(en vuelo -> reusa)
console.log('   isLoading en los 3 -> [' + [list, grid, srch].map((q) => q.isLoading).join(', ') + ']  (todos esperan la misma peticion)');
qc.flush(); // la respuesta llega
console.log('   fetches reales al backend -> ' + backend.calls + '   (uno solo: la key ya estaba en vuelo)');

// 2) CACHE: dato ya guardado, un 4o componente pide despues ----------------
console.log('\n2) CACHE — un 4o componente pide el mismo dato despues (clock=0, dentro de staleTime):');
const recs = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);  // Recommendations
console.log('   Recommendations -> data al instante: Mouse = ' + priceOf(recs.data, 'p1') +
  ' | isFetching=' + recs.isFetching);
console.log('   fetches al backend -> ' + backend.calls + '   (0 nuevos: leyo la copia)');

// 3) UNA SOLA COPIA por queryKey ------------------------------------------
const e = qc.cache.get(qc.hash(['products']));
console.log('\n3) Una sola copia por queryKey: los 4 componentes leen la MISMA entrada:');
console.log('   entradas de cache para ["products"] -> ' + qc.cache.size + '   (una, no cuatro)');
console.log('   Mouse en esa copia compartida -> ' + priceOf(e.data, 'p1'));

console.log('\nRegla: la queryKey coordina. 3 en vuelo -> 1 peticion (dedup); el 4o que llega -> 0 (cache).');

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== El cache y el dedup: N componentes, 1 fetch ===

1) DEDUP — 3 componentes usan useQuery(["products"]) antes de la respuesta:
   isLoading en los 3 -> [true, true, true]  (todos esperan la misma peticion)
   fetches reales al backend -> 1   (uno solo: la key ya estaba en vuelo)

2) CACHE — un 4o componente pide el mismo dato despues (clock=0, dentro de staleTime):
   Recommendations -> data al instante: Mouse = $25.99 | isFetching=false
   fetches al backend -> 1   (0 nuevos: leyo la copia)

3) Una sola copia por queryKey: los 4 componentes leen la MISMA entrada:
   entradas de cache para ["products"] -> 1   (una, no cuatro)
   Mouse en esa copia compartida -> $25.99

Regla: la queryKey coordina. 3 en vuelo -> 1 peticion (dedup); el 4o que llega -> 0 (cache).

Lee la corrida en tres partes.

Dedup — 3 en vuelo, 1 fetch. Los tres componentes declararon ['products'] antes de que llegara ninguna respuesta, así que los tres arrancaron en isLoading -> [true, true, true] (nadie tiene el dato). Pero cuando las respuestas llegaron, el backend recibió fetches -> 1. ¿Por qué uno y no tres? Porque el primer useQuery marcó la llave 'products' como en vuelo (el inflight del código; el pizarrón "ya se pidió"), y los otros dos, al ver la misma llave ya en vuelo, no encolaron otra petición: esperaron esa. Tres monturas simultáneas, una petición compartida. Ese es el mal "sin dedup" de M4, curado por declarar la misma llave.

Caché — el 4º, 0 fetches. El cuarto componente (Recommendations) pidió ['products'] después de que el dato ya volvió y con el reloj dentro de staleTime. Resultado: data al instante (Mouse = $25.99), isFetching=false, y fetches -> 0 nuevos (el contador siguió en 1). No hubo dedup aquí —no había nada en vuelo—; hubo cache hit: el dato ya estaba en el estante, se sirvió directo. Es el plato ya listo en la barra.

Una copia, no cuatro. La parte 3 lo cierra: aunque cuatro componentes pidieron ['products'], hay entradas de cache -> 1. No hay cuatro copias que puedan divergir (el bug de M1); hay una, compartida, con Mouse = $25.99. Esa es la raíz de todo: una copia por queryKey. De ahí salen la coherencia (todos ven lo mismo), el dedup (una petición en vuelo) y la caché (una lectura del estante).

Profundización: dedup vs caché, y por qué la llave es el coordinador

El momento distingue dedup de caché. Los dos evitan peticiones de más, pero en momentos distintos. El dedup actúa durante la petición: entre que el primer componente dispara el fetch y que la respuesta llega, cualquier otro componente que pida la misma llave se engancha a esa petición pendiente. La caché actúa después: una vez la respuesta llegó y se guardó, cualquier componente que pida la llave lee la copia. En una carga real de página, los dos ocurren: los componentes que montan juntos se dedupan (comparten el fetch inicial), y los que montan un poco después (o al navegar de vuelta) hacen cache hit.

Por qué la llave es el coordinador (y el useEffect no lo tenía). En M4, cada useEffect era una isla: no sabía que otro componente estaba pidiendo lo mismo, porque no había ninguna identidad compartida que los conectara. La queryKey es esa identidad. La capa lleva dos registros indexados por llave: uno de datos guardados (la caché) y uno de peticiones en vuelo (el inflight). Antes de disparar un fetch, consulta los dos: ¿tengo el dato guardado y fresco? → cache hit. ¿Hay una petición en vuelo para esta llave? → engancharse (dedup). ¿Ninguno? → disparar. Sin la llave, cada petición sería anónima y no se podría coordinar; con ella, N consumidores del mismo dato generan una petición.

Esto escala donde "levantar el estado" no. M4 lo señaló: la reacción instintiva de "subo el fetch al App y lo bajo por props" funciona para componentes con un ancestro cercano, pero se rompe cuando el mismo dato lo quiere media app en ramas lejanas (un modal, otra ruta, un carrusel). La caché por queryKey vive fuera del árbol, así que cualquier componente, en cualquier rama o ruta, que declare ['products'] se engancha a la misma copia —sin props, sin ancestro común—. La coordinación no depende de la forma del árbol, solo de la llave.

Errores comunes

Usar llaves ligeramente distintas para el mismo dato (romper el dedup sin querer). Qué pasa: un componente usa ['products'] y otro ['product-list'] para el mismo catálogo. Por qué pasa: no hay una convención de llaves y cada quien inventa la suya. Cómo detectarlo: ves dos fetches del mismo endpoint al cargar, aunque uses React Query. Cómo corregirlo: los componentes que quieren el mismo dato deben usar la misma queryKey, idéntica. Centraliza las llaves (funciones o constantes que las generen) para que no diverjan. Llaves distintas = datos distintos = sin dedup.

Crear un QueryClient por componente o por render. Qué pasa: se instancia new QueryClient() dentro de un componente en vez de una vez fuera del árbol. Por qué pasa: no se entiende que la caché es única y global. Cómo detectarlo: no hay dedup ni caché entre componentes; cada uno tiene su propia caché aislada. Cómo corregirlo: un QueryClient para toda la app, creado fuera del árbol y provisto con QueryClientProvider. La caché compartida es lo que permite compartir copias; una caché por componente anula todo el beneficio.

Creer que el dedup cubre peticiones en momentos distintos. Qué pasa: se espera que dos peticiones separadas en el tiempo (una ahora, otra en 10 segundos) se "dedupliquen". Por qué pasa: se confunde dedup con caché. Cómo detectarlo: te sorprende ver un refetch tras un rato, pensando que "ya se dedupó". Cómo corregirlo: el dedup solo une peticiones en vuelo al mismo tiempo. Lo que cubre "pedir de nuevo más tarde" es la caché más la política de frescura (staleTime, lección 5): si el dato sigue fresco, cache hit; si está stale, se revalida. Son mecanismos distintos para momentos distintos.

Ejercicios

Ejercicio 1 — Cuenta los fetches. Una página de Mercado monta, todos a la vez, cinco componentes que muestran el catálogo: ProductList, FeaturedGrid, SearchResults, Recommendations y MiniCatalog, todos con useQuery({ queryKey: ['products'], queryFn: fetchProducts }). (a) ¿Cuántos fetches al backend? (b) ¿Cuántas entradas de caché? (c) Si un sexto componente monta 2 segundos después (dentro de staleTime), ¿cuántos fetches hace?

Ver solución
  • (a) Uno. Los cinco declaran la misma queryKey y montan a la vez: el primero dispara el fetch, los otros cuatro se enganchan a esa petición en vuelo (dedup). Un fetch para los cinco.
  • (b) Una. Una copia por queryKey; los cinco leen la misma entrada ['products']. No hay cinco copias.
  • (c) Cero. El sexto monta después de que el dato ya volvió y dentro de staleTime (el dato sigue fresco): cache hit, lee la copia sin pedir nada. Fetches nuevos: 0.

Cinco monturas simultáneas + una tardía = un solo fetch total (mientras el dato siga fresco).

Ejercicio 2 — Dedup o caché. Para cada escenario, di si lo que evita el fetch de más es el dedup o la caché (o ninguno): (a) dos componentes montan en el mismo render y piden ['user', 1]; (b) navegas a la página de un producto, vuelves atrás, y el catálogo se muestra sin recargar; (c) pides ['products'] una hora después de la primera vez.

Ver solución
  • (a) Dedup. Los dos piden en el mismo momento, antes de que llegue la respuesta: la segunda montura se engancha a la petición en vuelo de la primera. Petición compartida = dedup.
  • (b) Caché. El catálogo ya se había cargado antes; al volver, el dato está en la caché (y probablemente fresco), así que se sirve del estante sin pedir. Cache hit = caché.
  • (c) Ninguno de los dos evita el fetch (probablemente). Una hora después, el dato casi seguro está stale (pasó staleTime), así que la capa hace stale-while-revalidate: sirve el cache viejo y refetchea. No es dedup (nada en vuelo) ni cache hit puro (el dato está viejo): es revalidación. (Lección 5.)

Ejercicio 3 — Explica el "1". Un compañero mira la pestaña de red y dice: "monté tres componentes que piden ['products'] y solo veo una petición a /products. ¿React Query se saltó dos?". Explícale qué pasó, con el pizarrón de la cocina.

Ver solución

No se saltó ninguna: coordinó tres pedidos en una petición. Cuando el primer componente pidió ['products'], la capa disparó el fetch y anotó esa llave como "en vuelo" (el pizarrón: "products — pedido"). Cuando el segundo y el tercero pidieron la misma llave un instante después —antes de que la respuesta llegara—, la capa miró su pizarrón, vio que ['products'] ya estaba en vuelo, y en vez de disparar dos peticiones más, enganchó al segundo y al tercero a la petición que ya existía. Los tres esperaron la misma respuesta.

Es exactamente el pizarrón de la cocina: tres meseros necesitan la misma pasta, el primero la anota, los otros dos ven que ya está pedida y esperan ese plato en vez de gritar "pasta" otra vez. Una anotación, un plato, tres meseros. En la red: una petición, tres componentes. Los tres reciben el dato cuando llega; simplemente no lo pidieron por triplicado. Eso es el dedup, y es correcto —de hecho es el objetivo—.

Resumen y siguiente paso

En esta lección cosechaste el primer beneficio de la queryKey: una copia por llave, compartida. De ahí salen la caché (el dato ya volvió → se sirve del estante, cero fetches) y el dedup (dos monturas simultáneas → una petición compartida en vuelo). Lo anclaste con el pizarrón "ya se pidió" de la cocina —el segundo mesero ve que la pasta ya está pedida y espera ese plato— y lo mediste ejecutando: 3 useQuery(['products']) simultáneos → 1 fetch (dedup), un 4º lector → 0 (cache hit), y 1 sola entrada de caché para los cuatro. Y viste la distinción por el momento: dedup mientras la petición está en vuelo, caché cuando el dato ya volvió.

Antes de avanzar deberías poder: explicar por qué N componentes con la misma queryKey generan un fetch; distinguir dedup (en vuelo) de caché (ya volvió); reconocer que la caché vive fuera del árbol en un único QueryClient; y contar los fetches de un escenario dado.

La lección 5 aborda el "cuándo" que aquí dejamos pendiente: el stale-while-revalidate, el comportamiento más importante de React Query. Vas a ver, ejecutado, cómo staleTime gobierna la frescura —dentro de la ventana, el cache se sirve sin fetch; pasada, la capa sirve el cache viejo al instante y refetchea en background, sin spinner—, y qué es gcTime. El corazón de por qué React Query se siente instantáneo.

Recursos