Módulo 5: Data Fetching With React Query

Mini-proyecto: los productos de Mercado con React Query

Descripción

Llegó el momento de juntar el módulo entero en una sola pieza. Vas a manejar los productos de Mercado de punta a punta con React Query, con las tres partes que ya sabes armar: la tabla de clasificación que ubica cada dato de Mercado en su herramienta (con los productos por fin en su caja: React Query), el código React real (el QueryClientProvider, el useQuery del catálogo y el de la búsqueda), y una sesión ejecutada en Node que combina dedup, stale-while-revalidate, queryKey por búsqueda y los estados, más un scorecard que mide el antes (fetching manual de M4) contra el después (React Query). Este mini-proyecto es la síntesis: reúne useQuery, la queryKey como identidad, la caché/dedup, el stale-while-revalidate y los estados, y cierra el lado de lectura del estado del servidor.

Conexión con el módulo. Es el capstone. Reúne las siete lecciones: el modelo declarativo de useQuery (L2), la queryKey como identidad (L3), la caché y el dedup (L4), el stale-while-revalidate (L5), los estados (L6) y la aplicación a Mercado (L7). Y cierra el arco de la guía hasta aquí: el estado del servidor (los productos) se resuelve con React Query; el que cambia seguido (el carrito) con un store (M3); el que cambia poco (tema, usuario) con Context (M2); y el de la URL (búsqueda/filtros) con searchParams (M7). Con esto terminas la lectura del estado del servidor y quedas listo para el módulo 6, donde aprendes a escribirlo (mutaciones e invalidación).

El plan: qué vamos a armar

El fetching de productos tiene tres reglas de diseño que salen del módulo:

  1. Clasifica antes de elegir. Cada pieza de estado de Mercado va en su herramienta (módulo 1). Los productos son estado del servidor —una copia en caché de datos del backend— → React Query. Ni Context, ni store, ni useState.
  2. Declara datos por su queryKey, no orquestes fetches. El catálogo es ['products'] (una copia, compartida por todos los componentes que lo muestran); una búsqueda es ['products', { query }] (una entrada por término, con el query en la llave). Eliges cuánto duran frescos (staleTime) y la capa hace el resto —dedup, revalidación, estados—.
  3. Lo que no es del servidor, fuera de React Query. El carrito no va aquí (cambia seguido → store, M3); el tema y el usuario tampoco (cambian poco → Context, M2); la búsqueda como texto tampoco (va en la URL → M7); un menú abierto tampoco (es local → useState).

La tabla de clasificación del estado

Antes de una línea de código, la decisión hecha explícita. Esta es la foto del estado de Mercado repartido en sus herramientas, con los productos por fin en su lugar:

Pieza de estado        Que es                             Herramienta
─────────────────────  ────────────────────────────────  ─────────────────────────────
products               del servidor (cache remoto)        React Query (este modulo)
search / filters       de la URL                          searchParams (M7)
cart (items)           global de cliente, cambia SEGUIDO  store + selectores (M3)
theme / user           global de cliente, cambia poco     Context (M2)
dropdown / menu abierto local a un componente             useState (M1)

Léela como el resumen de dónde estás en la guía: los productos por fin tienen su herramienta (este módulo); el carrito ya la tenía (store, M3); el tema y el usuario también (Context, M2); y queda una caja por resolver —la búsqueda/filtros en la URL (M7)—. Este proyecto arma la primera fila y deja las demás en su sitio.

El árbol y el flujo de datos

Este es el storefront con los productos manejados por React Query, en una caché fuera del árbol, consultada por queryKey. El carrito, el tema y la búsqueda aparecen aparte, apuntando a su herramienta:

flowchart TD
    QC["queryClient  (la cache, fuera del arbol: una copia por queryKey)"]
    App[App]
    App --> Header
    Header --> Search["SearchBar  (el texto -> URL, M7)"]
    App --> PL["ProductList  useQuery(['products'])"]
    App --> FG["FeaturedGrid  useQuery(['products'])"]
    App --> SR["SearchResults  useQuery(['products', { query }])"]
    App --> Cart["Cart  (store, M3)"]

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

Léelo con lo aprendido. El queryClient es la caché central, fuera del árbol, y los componentes de productos se conectan con líneas punteadas por su queryKey: ProductList y FeaturedGrid comparten ['products'] (una copia, un fetch), y SearchResults usa ['products', { query }] (su propia entrada por búsqueda). El Cart no toca el queryClient —vive en el store (M3)— y el texto de búsqueda va a la URL (M7). Cada caja, su herramienta; los productos, en React Query.

El código React real

Este es el fetching de Mercado tal como lo escribirías con React Query. Léelo pieza por pieza, notando dónde aterriza cada lección.

El Provider — la caché, una vez, fuera del árbol (L2, L4).

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 60_000 } },   // el catalogo se considera fresco 60s
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>   {/* la cache, fuera del arbol, para toda la app */}
      <Header />          {/* SearchBar */}
      <ProductList />     {/* useQuery(['products']) */}
      <FeaturedGrid />    {/* useQuery(['products']) -> comparte la copia */}
      <SearchResults />   {/* useQuery(['products', { query }]) */}
    </QueryClientProvider>
  );
}

El queryClient se crea una vez, fuera del árbol (L4: una caché global, no una por componente), y el QueryClientProvider lo hace accesible a todos los useQuery. El staleTime: 60_000 por defecto aplica la política de frescura a todas las queries (L5).

El catálogo — declaración compartida (L2, L4, L6).

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

function ProductList() {
  const { data, isLoading, isError, isFetching } = useQuery({
    queryKey: ['products'],       // la identidad del catalogo
    queryFn: fetchProducts,       // como pedirlo
  });
  if (isLoading) return <Spinner />;          // primera carga (L6)
  if (isError)   return <ErrorBox retry />;   // fallo (L6): manejar antes de leer data
  return (
    <div>
      {isFetching && <RefreshDot />}          {/* revalidacion en background (L5, L6) */}
      <ul>{data.map((p) => <ProductCard key={p.id} product={p} />)}</ul>
    </div>
  );
}
// FeaturedGrid declara el MISMO ['products'] -> comparte copia y fetch (dedup, L4).

ProductList declara ['products'] y lee los estados en el orden canónico (isLoadingisErrordata, L6). FeaturedGrid, con la misma llave, comparte la copia y el fetch (dedup, L4). Ninguno orquesta nada: declaran el dato.

La búsqueda — el query en la llave (L3, L7).

function SearchResults({ query }) {
  const { data, isFetching } = useQuery({
    queryKey: ['products', { query }],        // el termino, en la llave (L3): una entrada por busqueda
    queryFn: () => searchProducts(query),
    enabled: query.length > 0,                 // no busca con el input vacio (L7)
  });
  if (query.length === 0) return null;
  return (
    <div>
      {isFetching && <RefreshDot />}
      <ul>{data?.map((p) => <ProductCard key={p.id} product={p} />)}</ul>
    </div>
  );
}

SearchResults mete el query en la queryKey (L3), así que cada término tiene su entrada y volver a uno visto es un cache hit (L7). El enabled evita buscar con el input vacío. El formatPrice(2599)"$25.99" es el de siempre, dentro de ProductCard.

Ejemplo trabajado: una sesión, ejecutada

Vamos a correr el fetching de Mercado: primero la tabla de clasificación, luego el scorecard que mide el mismo escenario con fetching manual (M4) y con React Query (M5), y por fin una sesión completa —catálogo (dedup), búsquedas (una entrada por término, cache hit al volver) y revalidación (swr)—:

const backend = {
  calls: 0,
  _products: [
    { id: 'p1', name: 'Wireless Mouse', priceCents: 2599 },
    { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
    { id: 'p3', name: 'Mouse Pad', priceCents: 1200 },
  ],
  setMousePrice(c) { this._products[0].priceCents = c; },
  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);
const names = (list) => list.map((p) => p.name).join(', ');

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)) {
      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: 60000 };

// ---- 1) La tabla de clasificacion del estado de Mercado -----------------
console.log('=== 1) Clasificacion del estado de Mercado ===\n');
const stateMap = [
  ['products',          'estado del SERVIDOR (cache)',      'React Query (este modulo)'],
  ['search / filters',  'estado de la URL',                 'searchParams (M7)'],
  ['cart (items)',      'global de cliente, cambia SEGUIDO', 'store + selectores (M3)'],
  ['theme / user',      'global de cliente, cambia poco',    'Context (M2)'],
  ['dropdown abierto',  'local a un componente',             'useState (M1)'],
];
console.log('  Pieza               | Que es                              | Herramienta');
console.log('  --------------------|-------------------------------------|---------------------------');
stateMap.forEach(([p, w, t]) => console.log('  ' + p.padEnd(19) + ' | ' + w.padEnd(35) + ' | ' + t));

// ---- 2) Scorecard: el mismo escenario, manual (M4) vs React Query (M5) ---
console.log('\n=== 2) Scorecard: fetching manual (M4) vs React Query (M5) ===\n');

// MANUAL: 3 componentes, cada uno su fetch; una copia que nunca revalida.
backend.setMousePrice(2599);
backend.calls = 0;
const copies = [backend.fetchProducts(), backend.fetchProducts(), backend.fetchProducts()]; // 3 useEffect
const manualFetches = backend.calls;
backend.setMousePrice(1999);                    // el admin baja el precio en el backend
const manualPrice = priceOf(copies[0], 'p1');   // la copia manual nunca vuelve a pedir

// REACT QUERY: 3 componentes declaran la misma query; dedup + revalidacion.
backend.setMousePrice(2599);
const qc = createQueryClient();
backend.calls = 0; clock.now = 0;
qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);
qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);
qc.useQuery(['products'], () => backend.fetchProducts(), FRESH);
qc.flush();
const rqFetches = backend.calls;
backend.setMousePrice(1999);                    // mismo cambio del backend
clock.now = 61000;                              // pasa el staleTime -> revalida
qc.useQuery(['products'], () => backend.fetchProducts(), FRESH); // render 1: cache + background
qc.flush();                                     // llega la revalidacion
const rqPrice = priceOf(qc.cache.get(qc.hash(['products'])).data, 'p1');

const score = [
  ['fetches para 3 componentes',   manualFetches + '', rqFetches + ' (dedup)'],
  ['copia tras cambio del backend', manualPrice + ' (stale)', rqPrice + ' (revalida)'],
  ['estado loading/error',         'repetido por componente', 'uno por queryKey'],
  ['identidad del dato',           'ninguna', 'queryKey'],
];
console.log('  Requisito                      | Manual (M4)              | React Query (M5)');
console.log('  -------------------------------|--------------------------|--------------------');
score.forEach(([r, m, rq]) => console.log('  ' + r.padEnd(30) + ' | ' + m.padEnd(24) + ' | ' + rq));

// ---- 3) Sesion completa: catalogo + busqueda + revalidacion -------------
console.log('\n=== 3) Sesion de Mercado con React Query, ejecutada ===\n');
const app = createQueryClient();
backend.setMousePrice(2599);
backend.calls = 0; clock.now = 0;

console.log('a) El catalogo (dedup): ProductList + FeaturedGrid + SearchResults');
app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
app.flush();
console.log('   fetches -> ' + backend.calls + ' | productos -> ' + app.cache.get(app.hash(['products'])).data.length);

console.log('\nb) La busqueda (queryKey con el query):');
backend.calls = 0;
for (const query of ['mouse', 'keyboard', 'mouse']) {
  const before = backend.calls;
  const r = app.useQuery(['products', { query }], () => backend.search(query), FRESH);
  app.flush();
  const hit = backend.calls === before ? 'CACHE HIT' : 'fetch';
  const data = app.cache.get(app.hash(['products', { query }])).data;
  console.log('   buscar ' + ('"' + query + '"').padEnd(10) + ' -> ' + hit.padEnd(9) + ' -> ' + names(data));
}
console.log('   entradas de cache -> ' + app.cache.size + ' | fetches de busqueda -> ' + backend.calls);

console.log('\nc) Revalidacion del catalogo (swr) tras una oferta:');
backend.calls = 0;
backend.setMousePrice(1999);
clock.now = 61000;
const s1 = app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
console.log('   render 1: Mouse = ' + priceOf(s1.data, 'p1') + ' (cache) | isFetching=' + s1.isFetching);
app.flush();
const s2 = app.useQuery(['products'], () => backend.fetchProducts(), FRESH);
console.log('   render 2: Mouse = ' + priceOf(s2.data, 'p1') + ' (fresco) | isFetching=' + s2.isFetching);
console.log('   fetches en la revalidacion -> ' + backend.calls);

console.log('\nCada caja en su herramienta; los productos, del servidor, en React Query.');

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

=== 1) Clasificacion del estado de Mercado ===

  Pieza               | Que es                              | Herramienta
  --------------------|-------------------------------------|---------------------------
  products            | estado del SERVIDOR (cache)         | React Query (este modulo)
  search / filters    | estado de la URL                    | searchParams (M7)
  cart (items)        | global de cliente, cambia SEGUIDO   | store + selectores (M3)
  theme / user        | global de cliente, cambia poco      | Context (M2)
  dropdown abierto    | local a un componente               | useState (M1)

=== 2) Scorecard: fetching manual (M4) vs React Query (M5) ===

  Requisito                      | Manual (M4)              | React Query (M5)
  -------------------------------|--------------------------|--------------------
  fetches para 3 componentes     | 3                        | 1 (dedup)
  copia tras cambio del backend  | $25.99 (stale)           | $19.99 (revalida)
  estado loading/error           | repetido por componente  | uno por queryKey
  identidad del dato             | ninguna                  | queryKey

=== 3) Sesion de Mercado con React Query, ejecutada ===

a) El catalogo (dedup): ProductList + FeaturedGrid + SearchResults
   fetches -> 1 | productos -> 3

b) La busqueda (queryKey con el query):
   buscar "mouse"    -> fetch     -> Wireless Mouse, Mouse Pad
   buscar "keyboard" -> fetch     -> Mechanical Keyboard
   buscar "mouse"    -> CACHE HIT -> Wireless Mouse, Mouse Pad
   entradas de cache -> 3 | fetches de busqueda -> 2

c) Revalidacion del catalogo (swr) tras una oferta:
   render 1: Mouse = $25.99 (cache) | isFetching=true
   render 2: Mouse = $19.99 (fresco) | isFetching=false
   fetches en la revalidacion -> 1

Cada caja en su herramienta; los productos, del servidor, en React Query.

Lee la salida de principio a fin, porque en ella está el módulo entero funcionando junto.

La tabla de clasificación abre el proyecto con la decisión hecha explícita: los productos van en React Query (servidor), la búsqueda en la URL (M7), el carrito en un store (M3), el tema y el usuario en Context (M2), un menú abierto en useState. Cada pieza, su herramienta. Es la tesis de la guía —clasifica antes de elegir— aplicada a Mercado, con el estado del servidor por fin en su caja correcta.

El scorecard mide el antes contra el después en el mismo escenario. fetches para 3 componentes: manual 3, React Query 1 (dedup). copia tras cambio del backend: manual $25.99 (stale: nunca revalida), React Query $19.99 (revalida con stale-while-revalidate). estado loading/error: manual repetido en cada componente (que pueden divergir), React Query uno por queryKey (coherente). identidad del dato: manual ninguna (cada useEffect es una isla), React Query la queryKey (el coordinador). Cuatro requisitos, cuatro victorias —y todas salen de la misma idea: una copia por llave, gestionada por la capa—.

La sesión ejecuta el patrón completo. En (a), el catálogo: tres componentes → 1 fetch (dedup), los tres muestran los 3 productos. En (b), la búsqueda: "mouse" y "keyboard" hacen fetch (términos nuevos), y el regreso a "mouse" es un CACHE HITentradas de cache -> 3 (catálogo + dos búsquedas), fetches de busqueda -> 2 (el regreso no costó nada)—. En (c), la revalidación: tras la oferta y pasado staleTime, el catálogo muestra $25.99 viejo en el render 1 (isFetching=true) y $19.99 fresco en el render 2, con 1 fetch, sin spinner. Ese es el estado del servidor de Mercado resuelto: una copia compartida del catálogo, una entrada por búsqueda, revalidación automática —todo declarado, nada orquestado—.

Errores comunes

Meter los productos en un store o en Context "para compartirlos". Qué pasa: como el catálogo lo usan muchos componentes, se guarda en el store del carrito o en un Context. Por qué pasa: "lo comparte toda la app, es global". Cómo detectarlo: guardas la respuesta de un fetch en un store, y luego peleas a mano para mantenerla fresca (banderas de loading, refetch manual). Cómo corregirlo: "cuántos lo usan" no decide la caja; de quién es la verdad, sí (M4). La verdad de los productos vive en el backend → estado del servidor → React Query. El store y Context son para estado de cliente.

Crear un QueryClient por componente o dentro de App. Qué pasa: se instancia new QueryClient() en el render, así que se recrea y la caché se pierde. Por qué pasa: no se ve que la caché debe ser única y estable. Cómo detectarlo: no hay dedup ni caché entre componentes; cada uno pide por su cuenta. Cómo corregirlo: un QueryClient, creado fuera del árbol (a nivel de módulo), provisto con QueryClientProvider. Una caché estable es lo que permite compartir copias.

Usar la misma queryKey para el catálogo y las búsquedas. Qué pasa: SearchResults reusa ['products'], así que las búsquedas se cruzan con el catálogo o entre sí. Por qué pasa: "son productos". Cómo detectarlo: buscar muestra el catálogo completo, o los resultados de un término bajo otro. Cómo corregirlo: el catálogo es ['products']; una búsqueda es ['products', { query }] con el término en la llave (L3). Datos distintos, llaves distintas.

Ejercicios

Ejercicio 1 — Agrega la página de detalle. Quieres una página de detalle de producto que muestre un producto por su id y sus reseñas. (a) Escribe las dos useQuery con sus queryKey. (b) Si el usuario navega del catálogo al detalle del producto p1 y luego vuelve al catálogo, ¿el catálogo se recarga? (c) ¿Por qué el detalle no comparte caché con el catálogo aunque muestre el mismo producto?

Ver solución

(a)

const { data: product } = useQuery({ queryKey: ['product', id],            queryFn: () => fetchProduct(id) });
const { data: reviews } = useQuery({ queryKey: ['product', id, 'reviews'], queryFn: () => fetchReviews(id) });

(b) No se recarga (si vuelves dentro de gcTime, 5 min por defecto, y el dato sigue fresco por staleTime): la entrada ['products'] sigue en la caché, así que el catálogo se muestra al instante (cache hit). Si ya pasó staleTime, se muestra al instante y se revalida en background (swr) —sin spinner en ningún caso—.

(c) Porque tienen queryKey distintas: el catálogo es ['products'] (la lista completa) y el detalle es ['product', 'p1'] (un producto). La caché las trata como datos diferentes, cada uno su entrada —aunque p1 aparezca en ambos—. Compartir el dato de p1 entre los dos requeriría técnicas de sincronización de caché (como setQueryData al navegar), que son un tema avanzado; por defecto, llaves distintas = entradas distintas.

Ejercicio 2 — Lee el scorecard. Del scorecard, explica cada fila en términos de la analogía de la biblioteca: (a) por qué manual da 3 fetches y React Query 1; (b) por qué la copia manual queda en $25.99 y la de React Query pasa a $19.99; (c) qué significa "identidad del dato: ninguna" vs "queryKey".

Ver solución
  • (a) 3 vs 1. Manual: tres lectores van cada uno a la editorial por el mismo libro (tres useEffect, tres fetches). React Query: los tres le piden a la bibliotecaria por el mismo título, ella va una vez y les da la copia a todos (dedup, una petición).
  • (b) $25.99 vs $19.99. Manual: cada lector se quedó con su ejemplar y nadie vuelve a la editorial, así que cuando sale la edición nueva (el precio baja) siguen con la vieja ($25.99, stale para siempre). React Query: la bibliotecaria, pasado un tiempo, revisa si hay edición nueva y reemplaza la copia ($19.99, revalida).
  • (c) ninguna vs queryKey. Manual: cada pedido es anónimo, sin número de recibo, así que no hay forma de saber que dos lectores quieren lo mismo → no se coordinan. React Query: cada dato tiene su queryKey (su número de recibo), y por eso la bibliotecaria puede compartir copias, deduplicar y revalidar. La identidad es lo que permite toda la coordinación.

Ejercicio 3 — ¿Qué lección resuelve qué? El proyecto toca todo el módulo. Para cada pieza, di de qué lección es el concepto de fondo: (a) el queryClient creado fuera de App y provisto con QueryClientProvider; (b) ProductList y FeaturedGrid compartiendo ['products']; (c) SearchResults con ['products', { query }]; (d) el staleTime: 60_000 y la revalidación en background; (e) el orden isLoadingisErrordata.

Ver solución
  • (a) queryClient fuera del árboluseQuery y dónde vive la caché (L2) + una caché global, no por componente (L4): la caché única, fuera del árbol, provista a toda la app.
  • (b) ['products'] compartidola caché y el dedup (L4): la misma queryKey → una copia, un fetch para los dos componentes.
  • (c) ['products', { query }]la queryKey es la identidad (L3): el término en la llave → una entrada por búsqueda, sin cruzar datos.
  • (d) staleTime + revalidaciónstale-while-revalidate (L5): dentro de staleTime sirve el cache; pasado, cache al instante + refetch en background.
  • (e) isLoadingisErrordatalos estados (L6): el orden canónico que evita leer data a ciegas.

El proyecto integra el módulo: clasificar (L1), declarar con useQuery (L2), diseñar la queryKey (L3), aprovechar caché/dedup (L4), revalidar con staleTime (L5), y renderizar los estados (L6). Los productos quedaron en su caja, con la herramienta correcta.

Resumen y siguiente paso

En este mini-proyecto manejaste los productos de Mercado de punta a punta con React Query. Empezaste con la tabla de clasificación —productos → React Query; búsqueda → URL (M7); carrito → store (M3); tema/usuario → Context (M2); menú → local—, que hace explícita la decisión antes de escribir código. Montaste el fetching con un QueryClient fuera del árbol, el catálogo con useQuery(['products']) (dedup, revalidación, estados) y la búsqueda con useQuery(['products', { query }]) (una entrada por término). Y lo ejecutaste en una sesión con el scorecard antes/después: manual 3 fetches y copia stale $25.99 contra React Query 1 fetch (dedup) y $19.99 revalidado, más el catálogo (1 fetch), las búsquedas (2 fetches + cache hit al volver a "mouse") y la revalidación (swr, sin spinner). Ese es el módulo entero funcionando junto: clasificar, declarar datos por su queryKey, y dejar que la capa comparta, deduplique, revalide y exponga estados.

Antes de cerrar el módulo deberías poder: clasificar el estado de un storefront y ubicar los productos en React Query; montar el QueryClientProvider y las useQuery del catálogo y la búsqueda; explicar el scorecard antes/después; y justificar por qué los productos no van en el store ni en Context.

Y hacia dónde sigue la guía. Con este módulo terminaste la lectura del estado del servidor: declaras qué datos necesitas y React Query los trae, cachea, deduplica y revalida. Pero solo has leído. En la sesión, el precio lo bajó "el admin" —una caja negra—; nunca lo cambiaste tú desde la UI. El módulo 6 abre ese lado: las mutaciones. Vas a aprender useMutation para escribir (crear, editar, borrar), el ciclo write → invalidate → refetch (tras escribir, invalidas las queries afectadas para que se revaliden con la verdad nueva), y las optimistic updates (actualizar la UI antes de la respuesta y hacer rollback si falla). Con la lectura (M5) y la escritura (M6), el estado del servidor queda completo; después vendrá la URL (M7) y el capstone (M8).

Recursos