Módulo 5: Data Fetching With React Query

Presentación del módulo: de orquestar fetches a declarar datos

Por qué este módulo existe aquí

El módulo 4 te dejó con un diagnóstico completo y una promesa. El diagnóstico: el estado del servidor —los productos de Mercado, cuya verdad vive en el backend— no es tuyo, es un caché, y manejarlo con useState + useEffect produce cinco males, todos medidos: sin caché (tres componentes → tres fetches), sin dedup (dos monturas simultáneas → dos peticiones), race conditions (respuestas fuera de orden pintan la vieja), sin revalidación (la copia queda stale para siempre), y la máquina loading/error repetida en cada componente. La promesa: esos cinco males tienen una raíz —tratar un caché ajeno como estado propio, con una copia por componente— y una cura, una capa que gestione el caché por ti indexando cada dato por una queryKey. Y esa capa tiene nombre: React Query / TanStack Query.

Este módulo es donde por fin usas esa capa. Y lo primero que hay que grabar no es una función ni una opción, sino un cambio de mentalidad. Con useState + useEffect, tú orquestas el fetch: decides cuándo llamar a la red, guardas el resultado en un estado, prendes y apagas un loading, atrapas el error, y —si te acuerdas— escribes el guard para descartar respuestas viejas. Eres el director de orquesta de cada petición, en cada componente. Con React Query declaras: dices qué dato necesitas (su queryKey) y cómo pedirlo (su queryFn), y la librería se encarga del resto —la caché, el dedup, la revalidación, los estados—. Pasas de escribir cómo se obtiene el dato paso a paso, a describir qué dato quieres y leerlo. Esa es la diferencia entre imperativo (orquestar) y declarativo (declarar), y es el eje de todo el módulo.

Los conceptos concretos son cinco, y cada uno cierra un mal del módulo 4. La queryKey es la identidad del dato en la caché (['products'], ['products', { query }]): la llave que permite reconocer "esto es el mismo dato que ya tengo o que ya estoy pidiendo". La caché guarda una copia por queryKey, así que dos componentes con la misma llave comparten un solo dato y un solo fetch —eso es el dedup—. El stale-while-revalidate es el comportamiento por defecto de la librería: muestra el dato cacheado al instante y, si está viejo, refetchea en background para revalidar, sin que el usuario vea un spinner. Y los estados isLoading/isError/data/isFetching son la máquina que ya no reimplementas: la lees.

Un aviso honesto sobre el alcance. Este módulo enseña React Query del lado del cliente: useQuery para leer datos remotos, con su caché y su revalidación. Escribir (mutaciones, invalidación, optimistic updates) es el módulo 6. Y pedir datos en el servidor (SSR, Server Components, prefetch) es de nextjs-app-router-guide, que tiene su propio modelo. Aquí: leer datos remotos en el navegador, bien.

Conexión con el módulo

Esta lección es el puente entre el diagnóstico (M4) y la mecánica (el resto de M5). Recoge la cura que M4 nombró —una capa por queryKey— y le pone su API real, useQuery, mostrándote el módulo entero en miniatura: una corrida que demuestra los tres pilares (dedup, stale-while-revalidate, y la queryKey como identidad) en una sola ejecución. Cada uno de esos pilares es una lección completa por venir: useQuery y el modelo declarativo (L2), la queryKey como identidad (L3), la caché y el dedup (L4), el stale-while-revalidate con staleTime/gcTime (L5), y los cuatro estados (L6). Luego los aplicas a Mercado (L7) y los integras en el mini-proyecto (L8).

Y la promesa de siempre, cumplida: nada se cita de memoria, todo se ejecuta. React del navegador no se corre en un agente, así que el código React real —el useQuery, el QueryClientProvider— se muestra en los bloques (es la API exacta que escribirás), y la mecánica se ejecuta en Node con solo JavaScript. Modelamos un mini query client de unas treinta líneas —una caché por queryKey, con staleTime, dedup y revalidación— y una red determinista: una cola que resuelve las respuestas cuando le indicamos con flush(), para que la salida sea siempre idéntica. Cada salida en "Qué esperar" es la salida literal de correr el código con Node 18.

Una analogía: la biblioteca compartida con recibos

Instala esta imagen, porque la vas a usar todo el módulo. En el módulo 4 el problema eran diez lectores que, cada uno por su cuenta, viajaban a la editorial a pedir el mismo libro. La cura fue montar una biblioteca con una bibliotecaria. Ahora los lectores no van a la editorial: le piden el libro a ella, por su título (la queryKey). Y la bibliotecaria trabaja con recibos, que es lo que hace mágica a la biblioteca:

  • Si alguien ya pidió el libro, te dan la copia en mano. No vuelven a la editorial: el ejemplar está en el estante, te lo prestan al instante. Eso es la caché: una copia por título, servida sin ir a la red.
  • Mientras te dan la copia, revisan si hay edición nueva. No te hacen esperar a que confirmen con la editorial: te entregan lo que hay ya, y en paralelo verifican si salió una versión más reciente para reemplazarla si hace falta. Eso es el stale-while-revalidate: copia al instante, revisión en background.
  • Si dos lectores piden el mismo libro al mismo tiempo y la bibliotecaria ya fue por él, el segundo espera ese mismo ejemplar. No manda un segundo pedido a la editorial. Eso es el dedup: una petición en vuelo compartida.

Y hay una segunda imagen, más pequeña, para el dedup: el pizarrón de la cocina de un restaurante. Cuando un mesero pide un plato a la cocina, lo anota en el pizarrón: "mesa 4, pasta — pedido". Si otro mesero necesita lo mismo, mira el pizarrón, ve que ya se pidió, y no vuelve a gritarle al cocinero. Una anotación, un plato, aunque tres meseros lo quieran. El pizarrón es la caché por queryKey; "ya se pidió" es el dedup. Guarda las dos imágenes: la biblioteca con recibos (la caché y la revalidación) y el pizarrón (el dedup). El módulo entero es esto, medido.

El caso que nos acompaña: los productos de Mercado, ahora con su capa

Seguimos con el storefront de Mercado. Los componentes son los de siempre: App, SearchBar, ProductList con sus ProductCard, Cart, y los que aparecieron en M4 —FeaturedGrid (destacados) y SearchResults (resultados de búsqueda)—. Los datos, también los de siempre: un producto (Product) tiene id, name y priceCents (el precio en centavos, entero: 2599), y se muestra con formatPrice(2599)"$25.99".

En M4 viste el problema: ProductList, FeaturedGrid y SearchResults mostraban los mismos productos y cada uno pedía GET /products por su cuenta —tres fetches por el mismo dato—. Aquí ese problema desaparece con una sola idea: los tres declaran el mismo dato con la misma queryKey, y la capa se encarga de que haya un fetch y una copia:

flowchart TD
    QC["queryClient  (la cache, fuera del arbol: una copia por queryKey)"]
    App[App]
    PL["ProductList  useQuery(['products'])"]
    FG["FeaturedGrid  useQuery(['products'])"]
    SR["SearchResults  useQuery(['products', { query }])"]
    Backend[("Backend  (la verdad de /products)")]

    App --> PL
    App --> FG
    App --> SR
    PL -. "lee ['products']" .-> QC
    FG -. "lee ['products']" .-> QC
    SR -. "lee ['products', { query }]" .-> QC
    QC -. "1 fetch por queryKey" .-> Backend

Fíjate en la diferencia con el diagrama de M4: allá las tres flechas iban directo al backend (tres peticiones); aquí van al queryClient (la caché), y del queryClient sale una flecha al backend por cada queryKey distinta. ProductList y FeaturedGrid comparten ['products'] → una copia, un fetch. SearchResults usa ['products', { query }] → su propia entrada, porque es un dato distinto (los resultados de una búsqueda). La queryKey es lo que decide qué se comparte y qué se separa. En el resto del módulo lo desmenuzamos; aquí lo ves entero.

Ejemplo trabajado: el módulo en miniatura, ejecutado

Antes de estudiarlo pieza por pieza, veámoslo entero en una corrida. Primero, cómo se ve en React de verdad —esto es la API real de React Query, la que escribirás—:

import { useQuery } from '@tanstack/react-query';

// Cada componente DECLARA que dato necesita. No orquesta el fetch: lo describe.
function ProductList() {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['products'],                 // la identidad del dato (el titulo del libro)
    queryFn: fetchProducts,                 // como pedirlo (la editorial)
    staleTime: 60_000,                      // considera el dato fresco por 60s
  });
  if (isLoading) return <Spinner />;
  if (isError)   return <ErrorBox />;
  return <ul>{data.map((p) => <ProductCard key={p.id} product={p} />)}</ul>;
}
// FeaturedGrid usa el MISMO queryKey ['products'] -> comparte la copia y el fetch (dedup).
// SearchResults usa ['products', { query }] -> su propia entrada (dato distinto).

Ese useQuery es toda la superficie que toca el componente: declara la llave, la función, y lee data/isLoading/isError. Ahora la mecánica por dentro, ejecutada —un mini query client que hace lo mismo, y tres demostraciones: dedup, stale-while-revalidate, y la queryKey como identidad—:

// El backend es la fuente de la verdad; contamos cuantas veces lo llaman.
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 })); },
  search(q) { this.calls++; return this._products.filter((p) => p.name.toLowerCase().includes(q)); },
};
const priceOf = (list, id) => '$' + (list.find((p) => p.id === id).priceCents / 100).toFixed(2);

// ---- Mini query client: el modelo mental de React Query (~30 lineas, sin dependencias) ----
// La red es determinista: useQuery ENCOLA un fetch y flush() simula que las respuestas llegan.
const clock = { now: 0 };                                  // reloj manual para medir staleTime
function createQueryClient() {
  const cache = new Map();        // keyHash -> { data, updatedAt, status }
  const inflight = new Set();     // keyHash con un fetch ya encolado -> esto es el dedup
  const queue = [];               // cola determinista: los fetches pendientes
  const hash = (key) => JSON.stringify(key);               // la queryKey serializada = su identidad

  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)) {  // fetch si falta el dato o esta stale, y no hay otro en vuelo
      inflight.add(h);
      queue.push({ h, queryFn });
    }
    return {
      data: entry ? entry.data : undefined,
      isLoading: !hasData && inflight.has(h),              // primera carga: aun sin dato, con fetch en curso
      isFetching: inflight.has(h),                         // cualquier fetch en vuelo (incluye el de background)
      isError: errored,
    };
  }

  function flush() {                                       // "las respuestas de la red llegan"
    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 };   // consideramos el dato fresco por 5s

console.log('=== React Query en miniatura: dedup, stale-while-revalidate y la queryKey ===\n');

// 1) DEDUP -----------------------------------------------------------------
const qc = createQueryClient();
backend.calls = 0; clock.now = 0;
console.log('1) DEDUP — 3 componentes declaran useQuery(["products"]) a la vez (clock=0):');
const a = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH); // ProductList  (dispara el fetch)
const b = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH); // FeaturedGrid (comparte el fetch)
const c = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH); // SearchResults(comparte el fetch)
console.log('   isLoading en los 3 (sin dato aun) -> [' + [a, b, c].map((q) => q.isLoading).join(', ') + ']');
qc.flush(); // las respuestas de la red llegan
console.log('   fetches reales al backend         -> ' + backend.calls + '   (uno solo para los 3)');

// 2) STALE-WHILE-REVALIDATE -----------------------------------------------
console.log('\n2) STALE-WHILE-REVALIDATE — leer el mismo dato despues:');
backend.calls = 0;
const readFresh = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH); // clock sigue en 0: fresco
console.log('   (a) dentro de staleTime (clock=0): sirve el cache al instante, sin fetch');
console.log('       Mouse = ' + priceOf(readFresh.data, 'p1') + ' | isFetching=' + readFresh.isFetching + ' | fetches=' + backend.calls);

backend._products[0].priceCents = 1999; // el backend baja el precio (un admin, otro usuario)
clock.now = 6000;                        // pasan 6s: el dato queda STALE (6000 >= 5000)
const readStale = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH); // stale -> sirve cache Y refetchea
console.log('   (b) pasado staleTime (clock=6000): sirve el cache viejo AL INSTANTE y refetchea en background');
console.log('       render 1 (cache): Mouse = ' + priceOf(readStale.data, 'p1') + ' | isFetching=' + readStale.isFetching);
qc.flush(); // el refetch de background termina
const readAfter = qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);
console.log('       render 2 (fresh): Mouse = ' + priceOf(readAfter.data, 'p1') + ' | isFetching=' + readAfter.isFetching);
console.log('       fetches en la revalidacion -> ' + backend.calls + '   (uno, en background)');

// 3) LA queryKey ES LA IDENTIDAD ------------------------------------------
console.log('\n3) LA queryKey ES LA IDENTIDAD — distinto query, distinta entrada de cache:');
const qc2 = createQueryClient();
backend.calls = 0; clock.now = 0;
qc2.useQuery(['products', { query: 'mouse' }], () => backend.search('mouse'), FRESH);
qc2.useQuery(['products', { query: 'keyboard' }], () => backend.search('keyboard'), FRESH);
qc2.flush();
console.log('   entradas en el cache -> ' + qc2.cache.size + '   (una por queryKey distinta)');
for (const [h, e] of qc2.cache) console.log('       ' + h.padEnd(34) + ' -> ' + e.data.map((p) => p.name).join(', '));
console.log('   fetches -> ' + backend.calls + '   (uno por query; entradas distintas no se comparten)');

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

=== React Query en miniatura: dedup, stale-while-revalidate y la queryKey ===

1) DEDUP — 3 componentes declaran useQuery(["products"]) a la vez (clock=0):
   isLoading en los 3 (sin dato aun) -> [true, true, true]
   fetches reales al backend         -> 1   (uno solo para los 3)

2) STALE-WHILE-REVALIDATE — leer el mismo dato despues:
   (a) dentro de staleTime (clock=0): sirve el cache al instante, sin fetch
       Mouse = $25.99 | isFetching=false | fetches=0
   (b) pasado staleTime (clock=6000): sirve el cache viejo AL INSTANTE y refetchea en background
       render 1 (cache): Mouse = $25.99 | isFetching=true
       render 2 (fresh): Mouse = $19.99 | isFetching=false
       fetches en la revalidacion -> 1   (uno, en background)

3) LA queryKey ES LA IDENTIDAD — distinto query, distinta entrada de cache:
   entradas en el cache -> 2   (una por queryKey distinta)
       ["products",{"query":"mouse"}]     -> Wireless Mouse
       ["products",{"query":"keyboard"}]  -> Mechanical Keyboard
   fetches -> 2   (uno por query; entradas distintas no se comparten)

Lee la salida despacio: es el módulo entero en tres actos.

Dedup. Los tres componentes declararon useQuery(['products']) a la vez, los tres arrancaron en isLoading=true (nadie tiene el dato todavía), y cuando llegaron las respuestas el backend recibió fetches -> 1. Uno solo para los tres, porque la primera llamada marcó la llave 'products' como "en vuelo" (el pizarrón: "ya se pidió") y las otras dos, al ver la misma llave ya anotada, esperaron esa misma petición en vez de disparar otra. Tres consumidores, una petición. Ese es el mal "sin dedup" de M4, curado por declarar la misma queryKey.

Stale-while-revalidate. Aquí está el comportamiento estrella. En (a), leímos el dato dentro de staleTime (el reloj seguía en 0, y el dato es fresco por 5s): la capa sirvió $25.99 al instante, isFetching=false, fetches=0 —no fue a la red, el dato se considera fresco—. Luego el backend bajó el precio a $19.99 y pasaron 6 segundos, así que el dato quedó stale. En (b), al volver a leer, pasó lo mágico: la capa sirvió el cache viejo ($25.99) al instante (render 1, isFetching=true porque arrancó un refetch en background), y cuando ese refetch de background terminó, mostró $19.99 fresco (render 2, isFetching=false). Dos "renders": viejo primero, fresco después, sin spinner en medio. Es la bibliotecaria que te da la copia en mano mientras revisa si hay edición nueva.

La queryKey es la identidad. Dos búsquedas distintas ('mouse' y 'keyboard') usaron queryKey distintas —['products', { query: 'mouse' }] y ['products', { query: 'keyboard' }]—, y el resultado fue 2 entradas de caché y 2 fetches. Fíjate en cómo se ven las llaves serializadas: ["products",{"query":"mouse"}]. Esa cadena es la identidad del dato en la caché. Como las dos búsquedas tienen identidad distinta, la capa las guarda por separado, cada una con su copia. Si hubieran usado la misma llave, habrían compartido una entrada —y una habría pisado a la otra, como veremos en L3—.

Ese es el módulo: declarar datos por su queryKey, dejar que la capa comparta una copia y un fetch (caché + dedup), servir el cache al instante y revalidar en background (stale-while-revalidate), y separar datos distintos por su llave. Todo lo demás son detalles de estas tres ideas.

El mapa del módulo

Guarda esta ruta; es cómo cada lección construye "usar React Query":

Tema                              Leccion   Idea clave
────────────────────────────────  ────────  ──────────────────────────────────────────
useQuery: declarar, no orquestar  L2        declaras queryKey + queryFn; lees data/
                                            isLoading/isError. La capa hace el resto
La queryKey es la identidad       L3        la llave = identidad en el cache; debe
                                            incluir toda variable del queryFn
El cache y el dedup               L4        una copia por queryKey; N componentes con
                                            la misma llave -> 1 fetch (dedup)
Stale-while-revalidate            L5        dentro de staleTime: cache sin fetch;
                                            pasado: cache al instante + refetch (gcTime)
Los cuatro estados                L6        isLoading (1a carga) vs isFetching (todo
                                            fetch, incl. background); isError; data
React Query en Mercado            L7        catalogo ['products'] + busqueda
                                            ['products', { query }], todo junto
────────────────────────────────  ────────  ──────────────────────────────────────────
Los productos de Mercado con RQ   L8        el capstone: clasificacion + codigo + scorecard

La frontera: qué NO entra en este módulo

Saber la frontera te evita esperar cosas que llegan en otro módulo o en otra guía.

  • Escribir datos (mutaciones) es el módulo 6. Aquí solo lees con useQuery. Cambiar un dato en el backend (useMutation), invalidar las queries afectadas y hacer optimistic updates es M6. Si en una lección quieres "¿y cómo cambio el precio?", la respuesta es "M6".
  • Pedir datos en el servidor (SSR, Server Components, prefetch) es de nextjs-app-router-guide. Aquí React Query del lado del cliente: la caché vive en el navegador. Next.js tiene su propio modelo (y React Query se integra con él con HydrationBoundary), pero eso es allá.
  • La API, el backend y el contrato REST son de los ecosistemas de Backend / Architecture. Aquí el backend es una caja negra que tiene la verdad y a veces cambia; el queryFn es cómo lo llamas, no cómo se diseña.
  • El estado de cliente (Context y el store) fue M2 y M3. El tema, el usuario, el carrito ya tienen su herramienta. React Query es solo para el estado del servidor.
  • Performance (memoización, re-renders finos) es de la guía de performance. Aquí mencionamos que staleTime evita fetches, pero afinar re-renders es otra guía.

Errores comunes

Esperar que React Query sea "otro store" donde guardas cualquier cosa. Qué pasa: se mete estado de cliente (el carrito, un formulario, un modal abierto) en useQuery "porque también guarda datos". Por qué pasa: la palabra "caché" suena a "lugar donde pongo cosas". Cómo detectarlo: usas useQuery para datos que no vienen de una petición al backend. Cómo corregirlo: React Query es para estado del servidor —datos cuya verdad vive en el backend, que se ponen stale y hay que revalidar—. El carrito va en un store (M3), el tema en Context (M2), lo local en useState (M1). Cada caja, su herramienta.

Creer que declarar es "menos control". Qué pasa: alguien que viene del useEffect siente que perdió el control al no escribir el fetch a mano. Por qué pasa: lo imperativo se siente más "poderoso" porque ves cada paso. Cómo detectarlo: intentas "ayudar" a React Query con useEffect extra que refetchean o sincronizan. Cómo corregirlo: declarar es más control, no menos —controlas qué dato y cuándo es fresco (staleTime), sin escribir el cómo frágil (race, dedup, caché)—. Los useEffect extra pelean con la capa; déjala hacer su trabajo.

Saltar al código sin entender la queryKey. Qué pasa: se copia un useQuery(['data'], fn) de un ejemplo y se usa la misma llave ['data'] para todo. Por qué pasa: en la demo funciona con una sola query. Cómo detectarlo: dos datos distintos comparten llave y se pisan, o una búsqueda muestra resultados de otra. Cómo corregirlo: la queryKey es la identidad del dato (L3); dos datos distintos necesitan llaves distintas, y una query que depende de una variable debe incluir esa variable en la llave. Entiende la queryKey antes de escribir la segunda query.

Ejercicios

Ejercicio 1 — Traduce el diagnóstico a la cura. Para cada mal del módulo 4, di qué pieza de React Query lo resuelve: (a) tres componentes hacen tres fetches; (b) dos componentes montan a la vez y piden dos veces; (c) la copia queda stale tras un cambio del backend; (d) cada componente reimplementa loading/error; (e) una búsqueda pinta la respuesta vieja.

Ver solución
  • (a) tres fetches → la caché por queryKey. Los tres declaran ['products']; la capa sirve una copia, los demás la leen. Un fetch.
  • (b) dos requests → el dedup. La misma queryKey en vuelo se comparte: la segunda montura espera la primera petición en vez de disparar otra.
  • (c) copia stale → stale-while-revalidate + staleTime. Pasado staleTime, la capa sirve el cache y refetchea en background; la copia se actualiza sola.
  • (d) loading/error repetido → los estados por queryKey. isLoading/isError/data los expone la capa, una vez por llave, leídos por todos (L6).
  • (e) respuesta vieja pinta → la capa descarta respuestas viejas. React Query cancela/ignora las respuestas de queries superadas; ya no escribes el guard a mano.

Cada mal, una pieza. Y todas salen de la misma idea: una copia por queryKey, gestionada por la capa.

Ejercicio 2 — Lee la corrida. En el ejemplo, la lectura (a) dio fetches=0 y la (b) dio fetches -> 1. Explica por qué (a) no fue a la red pero (b) sí, y por qué en (b) el "render 1" mostró $25.99 aunque el backend ya decía $19.99.

Ver solución

(a) fetches=0: la lectura ocurrió con el reloj en 0, dentro de staleTime (5000). El dato guardado tenía updatedAt=0, así que su edad (0) era menor que staleTime: fresco. La capa sirvió el cache sin ir a la red. Ese es el ahorro de staleTime: dentro de la ventana fresca, cero fetches.

(b) fetches -> 1: para esta lectura el reloj estaba en 6000, y la edad del dato (6000 - 0 = 6000) superó staleTime (5000): stale. Al estar stale, la capa hizo stale-while-revalidate: sirvió el cache y disparó un refetch en background. Ese refetch fue la única petición: fetches -> 1.

Por qué el render 1 mostró $25.99: porque stale-while-revalidate sirve el cache al instante, antes de que el refetch termine. En ese primer render el dato nuevo ($19.99) todavía no había llegado, así que la capa mostró lo que tenía ($25.99, viejo) para no dejar la UI en blanco. Cuando el refetch de background volvió, el render 2 mostró $19.99. La UI nunca mostró un spinner: mostró el dato viejo y lo reemplazó por el nuevo.

Ejercicio 3 — Imperativo vs declarativo. Explica, con la analogía de la biblioteca, la diferencia entre "orquestar un fetch" (imperativo, useEffect) y "declarar un dato" (declarativo, useQuery). ¿Quién hace el trabajo en cada caso?

Ver solución

Orquestar (imperativo): eres el lector que va él mismo a la editorial. Decides cuándo salir, esperas en la fila, recibes el ejemplar, lo guardas, y si sale una edición nueva tienes que acordarte de volver. Tú haces cada paso: llamar a la red (fetch), guardar el resultado (setState), manejar la espera (loading), atrapar el error, descartar respuestas viejas (el guard). El trabajo es tuyo, en cada componente.

Declarar (declarativo): eres el lector que le pide el libro a la bibliotecaria por su título. No dices cómo conseguirlo ni cuándo ir a la editorial: dices qué quieres (queryKey) y dónde pedirlo (queryFn), y ella hace el resto —te da la copia del estante si la hay (caché), no manda dos pedidos si dos piden a la vez (dedup), revisa si hay edición nueva (revalidación), y te avisa si está llegando o se agotó (los estados)—. El trabajo lo hace la capa; tú solo declaras.

La diferencia de fondo: en imperativo describes el procedimiento (los pasos para obtener el dato); en declarativo describes el resultado que quieres (el dato) y dejas el procedimiento a la librería. Menos código tuyo, y —lo importante— menos código frágil (race, caché, dedup) que se te olvide escribir.

Resumen y siguiente paso

En esta presentación viste el salto que da el módulo: del diagnóstico de M4 (cinco males del fetching manual, una cura) a la solución, React Query, y su cambio de mentalidad —de orquestar fetches (imperativo) a declarar datos (declarativo)—. Conociste los cinco conceptos: la queryKey (identidad del dato), la caché (una copia por llave), el dedup (una petición compartida), el stale-while-revalidate (cache al instante + refetch en background) y los estados. Los anclaste con la biblioteca compartida con recibos (copia en mano mientras revisan si hay edición nueva) y el pizarrón de la cocina (ya se pidió), y los mediste ejecutando el módulo en miniatura: 3 useQuery(['products']) → 1 fetch (dedup), una lectura fresca sin fetch y una stale con cache + refetch (swr), y dos búsquedas con queryKey distinta → dos entradas separadas.

Antes de avanzar deberías poder: explicar la diferencia entre orquestar y declarar; nombrar los cinco conceptos y qué mal de M4 cierra cada uno; leer la corrida en miniatura (por qué 1 fetch, por qué el render viejo antes del fresco); y ubicar la frontera —este módulo lee (M5), escribir es M6, el servidor es nextjs—.

La lección 2 entra al corazón declarativo: useQuery. Vas a ver, ejecutado, el ciclo de vida de una query (loading → success), la lista completa de todo el trabajo imperativo que no escribes (el useState, el useEffect, los setState, el guard de race), y por qué la caché vive fuera del árbol en un QueryClientProvider. El primer paso concreto de "declarar en vez de orquestar".

Recursos