Módulo 4: Server State Is Different

Mini-proyecto: auditar el fetching de Mercado

Descripción

Llegó el momento de juntar el módulo entero en una sola pieza aplicada a Mercado. En este mini-proyecto haces una auditoría del fetching de productos del storefront: tomas el código real que muestra los productos con useState + useEffect (el patrón de react-fundamentals), lo pones a prueba en los cuatro escenarios del módulo, y mides el antes (roto) contra el después (una capa de caché). La entrega es doble: un scorecard ejecutado que compara ambos —fetches, número de copias, coherencia tras un cambio del backend— y una tabla de requisitos que especifica qué debe dar la capa de caché. Esa tabla es, literalmente, el checklist que los módulos 5 y 6 cumplen con React Query. No construyes la solución de producción aquí; la diagnosticas con precisión y dejas escrito qué tiene que resolver.

Es una auditoría, no un rediseño, a propósito: el objetivo del módulo 4 es que salgas capaz de mirar código de fetching y ver los males, medirlos, y nombrar la cura. Un ingeniero que puede auditar así entiende React Query cuando lo ve —reconoce cada opción como la respuesta a un mal que ya diagnosticó—, en vez de copiar recetas. Todo se ejecuta en Node: el antes y el después corren de verdad, con contadores reales, para que el scorecard no sea una opinión sino una medición.

Conexión con el módulo. Es el capstone del diagnóstico. Reúne las seis lecciones: la naturaleza del estado del servidor (L2), sus cuatro propiedades (L3), y los cinco males medidos —sin caché/dedup (L4), race/sin revalidación (L5), loading/error repetido (L6)— junto con la cura única (L7). Y cierra la frontera del módulo: aquí diagnosticas el problema de Mercado y especificas la capa; la implementación con React Query —useQuery, queryKey, staleTime, useMutation, invalidación— es el módulo 5 y el 6. Con esto terminas el módulo 4 y quedas listo para la solución.

El plan: qué vamos a auditar

La auditoría tiene tres partes, en orden:

  1. Medir el antes. El fetching manual de Mercado —ProductList, FeaturedGrid, SearchResults, cada uno con su useEffect(fetch('/products'), [])— puesto a prueba: cuántos fetches hace al montar, y qué pasa cuando un admin aplica una oferta (las copias divergen).
  2. Medir el después. Los mismos componentes leyendo de una capa de caché por queryKey: cuántos fetches, cuántas copias, y qué pasa tras la oferta (todas fresh).
  3. Entregar el diagnóstico. El scorecard antes/después y la tabla de requisitos de la capa —el checklist para el módulo 5—.

El árbol y el problema

Este es el storefront con los tres componentes que muestran productos, cada uno pidiendo por su cuenta al backend. Fíjate en las tres flechas al mismo endpoint:

flowchart TD
    App[App]
    PL["ProductList  (useEffect: fetch /products)"]
    FG["FeaturedGrid  (useEffect: fetch /products)"]
    SR["SearchResults  (useEffect: fetch /products)"]
    Backend[("Backend  (la verdad de /products)")]

    App --> PL
    App --> FG
    App --> SR
    PL -. "fetch #1" .-> Backend
    FG -. "fetch #2" .-> Backend
    SR -. "fetch #3" .-> Backend

Tres componentes, tres useEffect, tres peticiones al mismo /products, tres copias del mismo dato. Es el escenario que el módulo entero viene midiendo. La auditoría lo cuantifica y lo contrasta con la cura: una sola caché por queryKey, leída por los tres.

El código React real: el antes y el después

Así se ve el fetching de Mercado hoy (el antes) y como quedará con React Query (el después). Léelos lado a lado; el segundo es la meta que el módulo 5 implementa.

El antes — cada componente con su useEffect (tres copias, tres máquinas).

// ProductList.jsx — su propia copia y su propia maquina loading/error
function ProductList() {
  const [products, setProducts] = useState(null);
  const [isLoading, setLoading] = useState(true);
  const [error, setError]       = useState(null);
  useEffect(() => {
    fetch('/products')
      .then((r) => r.json())
      .then((d) => { setProducts(d); setLoading(false); })
      .catch((e) => { setError(e.message); setLoading(false); });
  }, []); // corre una vez; nunca revalida
  if (isLoading) return <Spinner />;
  if (error)     return <Error msg={error} />;
  return <ul>{products.map((p) => <ProductCard key={p.id} product={p} />)}</ul>;
}
// FeaturedGrid y SearchResults repiten EXACTAMENTE este bloque -> 3 fetches, 3 copias, 3 maquinas.

El después — todos leen de la capa por la misma queryKey (una copia, una máquina).

// Con React Query: cada componente DECLARA el dato por su queryKey; la capa hace el resto.
function ProductList() {
  const { data: products, isLoading, isError } = useQuery({
    queryKey: ['products'],                                  // la identidad del dato
    queryFn: () => fetch('/products').then((r) => r.json()),
  });
  if (isLoading) return <Spinner />;
  if (isError)   return <Error />;
  return <ul>{products.map((p) => <ProductCard key={p.id} product={p} />)}</ul>;
}
// FeaturedGrid y SearchResults usan el MISMO queryKey ['products']:
// comparten una copia -> 1 fetch, dedup, estado compartido, y revalidacion via invalidateQueries.

La diferencia no está en cuánto código escribes, sino en dónde vive el estado: en el antes, dentro de cada componente (se duplica); en el después, en la capa por queryKey (se comparte). Todo lo que audita este proyecto sale de esa diferencia.

Ejemplo trabajado: la auditoría, ejecutada

Vamos a correr la auditoría: el antes (fetching manual) y el después (capa de caché) en los mismos escenarios, con contadores reales, y al final el scorecard y la tabla de requisitos.

// MINI-PROYECTO M4: auditar el fetching de productos de Mercado.
// Medimos el ANTES (useState+useEffect en cada componente) contra el DESPUES (una capa de cache),
// en los mismos escenarios: cache, dedup, revalidacion, coherencia loading/error.

const backend = {
  calls: 0,
  data: { products: [{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599 }] },
  fetch(key) { this.calls++; return JSON.parse(JSON.stringify(this.data[key])); },
};
const price = (list) => '$' + (list[0].priceCents / 100).toFixed(2);

console.log('=== Auditoria: el fetching de productos de Mercado ===\n');

// El storefront: componentes que muestran los MISMOS products (misma verdad remota).
const COMPONENTS = ['ProductList', 'FeaturedGrid', 'SearchResults'];

// =========================================================================
// ANTES — cada componente con su useState+useEffect (copia propia)
// =========================================================================
console.log('--- ANTES: useState + useEffect en cada componente ---\n');
backend.calls = 0;
const copies = {}; // cada componente guarda SU copia
COMPONENTS.forEach((name) => { copies[name] = backend.fetch('products'); }); // 3 fetches al montar
console.log('1) montan los 3 componentes -> fetches: ' + backend.calls + '   (sin cache: uno por componente)');

// El backend baja el precio; solo SearchResults vuelve a pedir (los otros tienen deps [])
backend.data.products[0].priceCents = 1999;
copies['SearchResults'] = backend.fetch('products'); // solo este refetcha
console.log('\n2) baja el precio a $19.99; solo SearchResults re-pide:');
COMPONENTS.forEach((name) => {
  const tag = price(copies[name]) === '$19.99' ? 'fresh' : 'STALE';
  console.log('   ' + name.padEnd(15) + ' muestra ' + price(copies[name]) + '   <- ' + tag);
});
console.log('   >> el MISMO mouse con dos precios a la vez en pantalla (copias que divergen).');
console.log('\n   marcador ANTES: fetches=' + backend.calls + ', copias=' + COMPONENTS.length +
  ', coherentes=NO');

// =========================================================================
// DESPUES — una capa de cache por queryKey (una copia compartida)
// =========================================================================
console.log('\n--- DESPUES: una capa de cache por queryKey ---\n');
function createCacheLayer(backend) {
  const entries = new Map();
  function ensure(key) {
    if (!entries.has(key)) entries.set(key, { status: 'idle', data: null, error: null });
    return entries.get(key);
  }
  return {
    useQuery(key) {
      const e = ensure(key);
      if (e.status === 'idle') { e.status = 'success'; e.data = backend.fetch(key); } // 1 fetch, dedup
      return e;
    },
    invalidate(key) { ensure(key).status = 'idle'; },
  };
}
// reiniciamos el backend para comparar desde el mismo punto
backend.data.products[0].priceCents = 2599;
backend.calls = 0;
const cache = createCacheLayer(backend);

const results = COMPONENTS.map((name) => cache.useQuery('products')); // 3 lecturas, 1 fetch
console.log('1) montan los 3 componentes -> fetches: ' + backend.calls + '   (con cache+dedup: uno solo)');
console.log('   leen el MISMO objeto? -> ' + (results[0] === results[1] && results[1] === results[2]));

console.log('\n2) baja el precio a $19.99; invalidamos la queryKey UNA vez:');
backend.data.products[0].priceCents = 1999;
cache.invalidate('products');
const afterList = COMPONENTS.map((name) => cache.useQuery('products')); // primero refetcha, resto cache
COMPONENTS.forEach((name, i) => {
  console.log('   ' + name.padEnd(15) + ' muestra ' + price(afterList[i].data) + '   <- fresh');
});
console.log('   >> una sola verdad: imposible que diverjan.');
console.log('\n   marcador DESPUES: fetches=' + backend.calls + ', copias=1, coherentes=SI');

// =========================================================================
// Scorecard + requisitos de la capa (lo que M5-M6 construyen)
// =========================================================================
console.log('\n=== Scorecard ===\n');
console.log('   Escenario                 | ANTES (useState+useEffect) | DESPUES (capa de cache)');
console.log('   --------------------------|----------------------------|------------------------');
console.log('   fetches al montar (3 comp)| 3                          | 1');
console.log('   copias del dato           | 3 (una por componente)     | 1 (por queryKey)');
console.log('   tras cambiar el backend   | divergen (25.99/25.99/19.99)| todas fresh');
console.log('   estado loading/error      | 3 maquinas independientes  | 1 estado compartido');

console.log('\n=== Requisitos de la capa (lo que la herramienta debe dar) ===\n');
[
  'indexar cada dato remoto por una queryKey (identidad unica)',
  'cachear: la 2a lectura NO refetcha',
  'dedup: peticiones identicas en vuelo se comparten',
  'revalidar: invalidar una key -> refetch controlado',
  'exponer un estado {isLoading, isError, data} por key',
  'descartar respuestas viejas (race safety)',
].forEach((r, i) => console.log('   ' + (i + 1) + '. ' + r));

console.log('\nEsa herramienta es React Query / TanStack Query. El diagnostico esta hecho;');
console.log('la solucion (useQuery, la mecanica del cache) es el modulo 5.');

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

=== Auditoria: el fetching de productos de Mercado ===

--- ANTES: useState + useEffect en cada componente ---

1) montan los 3 componentes -> fetches: 3   (sin cache: uno por componente)

2) baja el precio a $19.99; solo SearchResults re-pide:
   ProductList     muestra $25.99   <- STALE
   FeaturedGrid    muestra $25.99   <- STALE
   SearchResults   muestra $19.99   <- fresh
   >> el MISMO mouse con dos precios a la vez en pantalla (copias que divergen).

   marcador ANTES: fetches=4, copias=3, coherentes=NO

--- DESPUES: una capa de cache por queryKey ---

1) montan los 3 componentes -> fetches: 1   (con cache+dedup: uno solo)
   leen el MISMO objeto? -> true

2) baja el precio a $19.99; invalidamos la queryKey UNA vez:
   ProductList     muestra $19.99   <- fresh
   FeaturedGrid    muestra $19.99   <- fresh
   SearchResults   muestra $19.99   <- fresh
   >> una sola verdad: imposible que diverjan.

   marcador DESPUES: fetches=2, copias=1, coherentes=SI

=== Scorecard ===

   Escenario                 | ANTES (useState+useEffect) | DESPUES (capa de cache)
   --------------------------|----------------------------|------------------------
   fetches al montar (3 comp)| 3                          | 1
   copias del dato           | 3 (una por componente)     | 1 (por queryKey)
   tras cambiar el backend   | divergen (25.99/25.99/19.99)| todas fresh
   estado loading/error      | 3 maquinas independientes  | 1 estado compartido

=== Requisitos de la capa (lo que la herramienta debe dar) ===

   1. indexar cada dato remoto por una queryKey (identidad unica)
   2. cachear: la 2a lectura NO refetcha
   3. dedup: peticiones identicas en vuelo se comparten
   4. revalidar: invalidar una key -> refetch controlado
   5. exponer un estado {isLoading, isError, data} por key
   6. descartar respuestas viejas (race safety)

Esa herramienta es React Query / TanStack Query. El diagnostico esta hecho;
la solucion (useQuery, la mecanica del cache) es el modulo 5.

Lee la auditoría de principio a fin, porque es el módulo entero aplicado a Mercado.

El antes, medido. Los tres componentes montaron e hicieron fetches: 3 —cada useEffect pidió /products por su cuenta—. Luego un admin bajó el precio a $19.99, y solo SearchResults volvió a pedir (imagina que remontó, o que su efecto se disparó de nuevo); los otros dos, con [], se quedaron con su copia vieja. Resultado: ProductList y FeaturedGrid muestran $25.99 (stale) mientras SearchResults muestra $19.99 (fresh) —el mismo mouse con dos precios a la vez en pantalla, el bug de copias divergentes del módulo 1—. El marcador del antes: fetches=4 (tres al montar + uno del refetch parcial), copias=3, coherentes=NO.

El después, medido. Los mismos tres componentes, ahora leyendo de la capa por 'products', montaron con fetches: 1 —uno pidió, los otros dos leyeron la copia (leen el MISMO objeto? -> true)—. Cuando el precio bajó a $19.99, bastó una invalidate('products') para que la única copia se refrescara: los tres muestran $19.99 (fresh), porque leen la misma entrada. >> una sola verdad: imposible que diverjan. El marcador del después: fetches=2 (uno al montar + uno tras invalidar), copias=1, coherentes=SI.

El scorecard resume la auditoría en cuatro filas, y todas cuentan la misma historia: el antes tiene el problema (3 fetches, 3 copias, divergen, 3 máquinas); el después lo cura (1 fetch, 1 copia, todas fresh, 1 estado compartido). No es una opinión: son números de una corrida. Y la diferencia, en las cuatro filas, es la misma decisión —una copia por queryKey en vez de una por componente—.

La tabla de requisitos es la entrega más valiosa del proyecto: seis puntos que la capa debe cumplir. Léela como el contrato que el módulo 5 va a firmar —cada requisito es una función de React Query—: queryKey (identidad), caché, dedup, invalidateQueries (revalidar), isLoading/isError/data (estado compartido), y el descarte de respuestas viejas (race safety). Cuando en el módulo 5 escribas tu primer useQuery, esta tabla será la lista de cosas que la línea de código te está dando gratis.

Errores comunes

Auditar sin medir (opinar en vez de contar). Qué pasa: se dice "el fetching está mal" sin números, y el equipo no se convence. Por qué pasa: el problema es invisible en la demo, así que sin medición parece teórico. Cómo detectarlo: discusiones sobre si "vale la pena" cambiar, sin datos. Cómo corregirlo: mide —cuenta los fetches en la pestaña de red, reproduce la divergencia tras un cambio del backend, muestra el scorecard—. Los números (3 vs 1, divergen vs fresh) hacen el caso que las opiniones no.

Confundir el diagnóstico con la solución. Qué pasa: tras la auditoría, se intenta implementar la capa de producción a mano en el mismo commit. Por qué pasa: el modelo mínimo se ve simple y el impulso de "arreglarlo ya" es fuerte. Cómo detectarlo: empiezas a escribir tu propia caché con staleTime, refetch al enfocar, reintentos… reimplementando React Query. Cómo corregirlo: este proyecto diagnostica y especifica; la implementación es adoptar React Query (M5), no escribir la capa. La tabla de requisitos es el puente: dice qué hace falta, y la herramienta lo cumple.

Auditar solo los productos y olvidar el resto del estado. Qué pasa: se corrige el fetching de productos y se da por terminada la gestión de estado de Mercado. Por qué pasa: los productos fueron el foco del módulo. Cómo detectarlo: el carrito, el tema o la búsqueda siguen mal clasificados. Cómo corregirlo: recuerda la foto completa del módulo 1 —los productos son servidor (React Query), pero el carrito es global de cliente (store, M3), el tema es global de cliente estable (Context, M2), y la búsqueda/filtros van en la URL (M7)—. La auditoría del servidor es una pieza; cada caja tiene su herramienta.

Ejercicios

Ejercicio 1 — Amplía la auditoría. Mercado agrega un Recommendations y un ProductDetailPanel, ambos mostrando el mismo /products. (a) En el antes, ¿cuántos fetches al montar (con los cinco componentes)? (b) En el después, ¿cuántos? (c) Tras una oferta, ¿cuántas copias pueden divergir en cada caso?

Ver solución
  • (a) Cinco. Cada uno de los cinco componentes ejecuta su propio useEffect(fetch('/products'), []) al montar —cinco islas, cinco peticiones—.
  • (b) Una. Con la capa por 'products', el primero que monta hace la petición real y guarda la copia; los otros cuatro la leen. Cinco consumidores, un fetch.
  • (c) En el antes, hasta cinco copias pueden divergir: cada componente tiene la suya, y tras una oferta algunas se actualizan y otras no, así que podrías ver hasta cinco precios distintos del mismo producto. En el después, cero: hay una sola copia por queryKey, así que invalidarla refresca a los cinco a la vez —imposible que diverjan—. Cuantos más componentes muestren el dato, peor es el antes y más obvio el valor de la capa.

Ejercicio 2 — Traza el requisito. Para cada requisito de la tabla, di qué mal del módulo resuelve y en qué lección lo mediste: (a) "cachear: la 2ª lectura no refetcha"; (b) "descartar respuestas viejas"; (c) "un estado {isLoading, isError, data} por key"; (d) "invalidar una key → refetch".

Ver solución
  • (a) cachear → resuelve el mal sin caché (N componentes → N fetches), medido en la lección 4 (3 fetches → 1).
  • (b) descartar respuestas viejas → resuelve la race condition (respuestas fuera de orden pintan la vieja), medida en la lección 5 (el guard con id creciente).
  • (c) un estado por key → resuelve el loading/error repetido (máquinas que se desincronizan), medido en la lección 6 (tres estados distintos para el mismo dato → uno compartido).
  • (d) invalidar → refetch → resuelve la falta de revalidación (la copia queda stale porque useEffect([]) corre una vez), medida en la lección 5 (copia stale → capa que revalida).

Y el requisito que los sostiene a todos, la queryKey, es la identidad que permite caché, dedup, invalidación y estado compartido —el coordinador que el useEffect crudo no tiene (lección 4)—.

Ejercicio 3 — Redacta el veredicto. Escribe, en tres o cuatro frases, el veredicto de la auditoría que le presentarías al equipo: qué problema tiene el fetching actual (con un número), qué cura se propone, y qué herramienta la implementa. Sé concreto.

Ver solución

Un veredicto posible:

"El fetching de productos usa useState + useEffect en cada componente, lo que produce 3 peticiones al mismo /products al cargar la home (una por componente que lo muestra) y, tras cualquier cambio del backend, copias que divergen —vimos el mismo producto con dos precios a la vez en pantalla—, además de estados loading/error incoherentes entre componentes. La causa es tener una copia del dato por componente en vez de una copia compartida. La cura es una capa de caché por queryKey que centraliza una sola copia por dato remoto —bajando los fetches a 1, eliminando la divergencia y unificando el estado de carga—. Esa capa es React Query / TanStack Query, que cumple los seis requisitos de la tabla; proponemos adoptarla para el estado del servidor (los productos), manteniendo el carrito en el store y el tema en Context."

Lo importante del veredicto: lleva un número (3 fetches, divergencia), nombra la causa (copia por componente), la cura (capa por queryKey) y la herramienta (React Query) —sin implementarla, porque eso es el módulo 5—.

Resumen y siguiente paso

En este mini-proyecto auditaste el fetching de productos de Mercado de punta a punta. Mediste el antesuseState + useEffect en cada componente: 3 fetches al montar, 3 copias que divergen tras una oferta (el mismo mouse con dos precios), 3 máquinas loading/error incoherentes— contra el después —una capa de caché por queryKey: 1 fetch, 1 copia compartida, todas fresh tras una invalidate, 1 estado compartido—. Entregaste el scorecard ejecutado (números, no opiniones) y la tabla de requisitos de la capa: indexar por queryKey, cachear, deduplicar, revalidar, exponer un estado compartido y descartar respuestas viejas. Ese es el módulo entero aplicado: ver los males, medirlos, y especificar la cura —sin implementarla, porque la implementación es la solución del próximo módulo—.

Antes de cerrar el módulo deberías poder: auditar código de fetching y medir sus males; contrastar el antes/después con números; redactar un veredicto con causa, cura y herramienta; y escribir la tabla de requisitos que la capa debe cumplir.

Y hacia dónde sigue la guía. Con este módulo terminaste el diagnóstico del estado del servidor: sabes que no es tuyo (es un caché), conoces sus cuatro propiedades, mediste los cinco males del fetching manual, y especificaste la capa que los cura. El módulo 5 salda la deuda: te enseña React Query / TanStack Query, la capa real, con su mecánica —useQuery, la queryKey, la caché, el stale-while-revalidate (muestra la copia al instante y revalida en fondo), el dedup de peticiones, y los estados isLoading/isError/data—, todo ejecutado con un mini-cache para que veas por dentro lo que la tabla de requisitos pidió. Y el módulo 6 cierra con las mutaciones: useMutation, el ciclo write → invalidate → refetch, y las optimistic updates. La tabla que escribiste hoy es el índice de esos dos módulos: cada requisito, una lección.

Recursos