Módulo 8: Project Build Mercados Storefront

Carga los productos con un efecto

Descripción

En la lección anterior, el App recibía sus products como una constante escrita a mano —utilería para la foto—. Pero una tienda real no tiene su catálogo incrustado en el código: lo pide a un servidor. Esta lección conecta la primera pieza de vida del storefront: el App, al montarse, carga sus productos con un efecto. El estado products arranca en null —que significa "aún no llegó nada", y la UI muestra Loading...—, el efecto le pide los datos al servidor (que simulamos, determinista), y cuando la respuesta llega, setProducts los guarda y dispara un re-render que ya muestra el catálogo. Y como todo efecto que toca el mundo externo, lleva su cleanup: si el componente se desmonta antes de que la respuesta llegue, la protección descarta ese resultado en lugar de intentar guardarlo en un componente que ya no existe.

Conexión con el módulo. Es la segunda capa del capstone, y es el módulo 6 aplicado al storefront. Pedirle datos a un servidor es un efecto secundario —tocas la red, un sistema externo— y por eso va en useEffect, no en el render (M6, lección 2). El array de dependencias [] dice "corre solo al montar" (M6, lección 3). El estado products en null para distinguir "cargando" de "cargado" es el patrón de fetch (M6, lección 5). Y el cleanup que invalida la carga si el componente se va es la protección del módulo 6 (lecciones 4 y 6). Aquí lo montamos sobre el andamiaje de la lección 2: el App deja de recibir products por prop y pasa a cargarlos él mismo.

Y marca una frontera importante, la misma que el módulo 6 delimitó: este useEffect que carga datos "a mano" es lo que en producción resuelve por ti una librería de datos (React Query, SWR) o el fetch en el servidor (Next.js) —temas de las guías frontend-state-and-data y nextjs-app-router—. Aquí lo hacemos crudo, con useEffect, porque es el fundamento sobre el que esas herramientas se construyen: para saber cuándo dejar que otra cosa lo maneje, primero hay que saber hacerlo a mano.

Una analogía: encender la casa el día de la mudanza

Vuelve a la casa modelo de la lección anterior: la estructura estaba en pie, pero inerte —sin luz, sin agua—. El día que te mudas, pasa algo concreto: al entrar, mandas conectar los servicios. Levantas el teléfono, llamas a la compañía de luz, y pides que activen el medidor. Mientras esperas, la casa está a oscuras: sabes que la corriente viene en camino, pero todavía no llegó. Cuando la compañía activa el servicio, la luz prende, y recién entonces puedes usar la casa de verdad.

Fíjate en la secuencia, porque es exactamente la de un efecto de carga. Entrar a la casa es el componente montándose. Mandar conectar la luz es el efecto que corre justo después de montar: pide el servicio a alguien de afuera (la compañía = el servidor). La casa a oscuras mientras esperas es el estado products = null, el Loading...: la UI ya está pintada, pero sin datos. La luz que prende es la respuesta que llega: setProducts guarda los datos y la casa se ilumina (re-render con el catálogo). Y el cleanup es la cortesía de cancelar el pedido si te arrepientes y te vas antes de que conecten: no dejas un servicio activándose para una casa que ya desocupaste.

Lo clave de la analogía es el orden y la espera. No enciendes la luz mientras construyes la casa (no pides datos durante el render); primero terminas de entrar (montas y pintas el Loading...), y después mandas conectar (corre el efecto). Y aceptas que hay un rato a oscuras (el estado de carga) porque la corriente viene de afuera y tarda. Esa separación —pintar primero, sincronizar con el afuera después— es la razón de ser de useEffect.

Ejemplo trabajado: el App que carga sus productos

Primero, cómo se ve en React real. El App guarda products en estado (arrancando en null) y, con un useEffect, lo carga al montar:

import { useEffect, useState } from 'react';

function App() {
  const [products, setProducts] = useState(null); // null = cargando

  useEffect(() => {
    let active = true;                          // esta ejecucion tiene su bandera
    fetchProducts().then((data) => {
      if (active) setProducts(data);            // solo guardo si el App sigue montado
    });
    return () => { active = false; };           // cleanup: invalido esta carga si me desmonto
  }, []);                                        // [] = corre solo al montar

  if (products === null) {
    return <p className="loading">Loading...</p>;
  }
  return (
    <main className="storefront">
      <SearchBar query="" />
      <ProductList products={products} />
      <Cart items={[]} />
    </main>
  );
}

Léelo línea por línea, porque cada una es una lección del módulo 6:

  • useState(null) para products: null significa "aún no llegó la respuesta" → mostramos Loading.... Distinto de [] (que sería "cargó, y no hay productos"). Es el patrón de fetch (L5).
  • fetchProducts() dentro del efecto: pedir al servidor es tocar la red, un sistema externo → va en useEffect, no en el cuerpo del render (L2). Ponerlo en el render dispararía una petición en cada render, un desastre.
  • [] como dependencias: el efecto no depende de ningún valor que cambie, así que corre una sola vez, al montar (L3). Es la carga inicial del catálogo.
  • let active = true + if (active) + return () => { active = false; }: el cleanup. Si el App se desmonta antes de que la respuesta llegue, la bandera active pasa a false, y el callback descarta el resultado en vez de llamar setProducts en un componente que ya no está (L4, L6).

Ahora ejecutémoslo. Como React del navegador no corre aquí, modelamos un mini-runtime con useState, useEffect y cleanup (como en los módulos 3 y 6), y un servidor simulado determinista que responde cuando se lo pedimos —para ver la secuencia paso a paso, sin azar—:

function formatPrice(cents) { return '$' + (cents / 100).toFixed(2); }

const ALL = [
  { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599, category: 'peripherals', inStock: true },
  { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900, category: 'peripherals', inStock: false },
  { id: 'p3', name: 'USB-C Hub',           priceCents: 3499, category: 'peripherals', inStock: true },
  { id: 'p4', name: 'Laptop Stand',        priceCents: 4500, category: 'furniture',   inStock: true },
  { id: 'p5', name: 'Desk Lamp',           priceCents: 1999, category: 'furniture',   inStock: true },
];

// "Servidor" simulado determinista: guarda la peticion y la entrega cuando se lo pidamos.
function makeServer() {
  let pending = null;
  return {
    load(onResult) { pending = onResult; console.log('  [network] GET /products enviado al servidor'); },
    respond() { const cb = pending; pending = null; cb(ALL); },
  };
}

// --- mini-runtime: useState + useEffect + cleanup (montaje), como en el modulo 6 ---
let cells = [];
let cursor = 0;
let mounted = false;
let effectFn = null;
let effectCleanup = null;

function useState(initial) {
  const i = cursor++;
  if (!(i in cells)) cells[i] = initial;
  const setState = (value) => { cells[i] = value; render(); };
  return [cells[i], setState];
}
function useEffect(fn) {
  if (!mounted) effectFn = fn; // efecto de montaje: deps []
}
function render() { cursor = 0; App(); }
function commit() {
  if (effectFn && !mounted) {
    console.log('  [commit] DOM pintado; ahora corre el efecto');
    effectCleanup = effectFn();
    mounted = true;
  }
}
function unmount() {
  if (effectCleanup) { console.log('  [unmount] el App se va: corre su cleanup'); effectCleanup(); }
}

const server = makeServer();
function App() {
  const [products, setProducts] = useState(null); // null = cargando

  useEffect(() => {
    let active = true;
    console.log('  [effect]  cargar los productos al montar');
    server.load((data) => {
      if (!active) { console.log('  [ignore]  respuesta descartada (App desmontado)'); return; }
      console.log(`  [effect]  llegaron ${data.length} productos -> setProducts`);
      setProducts(data);
    });
    return () => { active = false; console.log('  [cleanup] active = false'); };
  });

  const label = products === null ? 'Loading...' : products.map((p) => p.name).join(', ');
  console.log(`  [render]  products = ${products === null ? 'null' : products.length} -> "${label}"`);
}

console.log('=== Cargar los productos de Mercado al montar (useEffect) ===\n');
console.log('1) Montaje: render inmediato (Loading...), luego el efecto pide al servidor:');
render();   // primer render: products es null -> Loading...
commit();   // DOM pintado -> corre el efecto -> pide al servidor

console.log('\n2) El servidor responde: setProducts dispara un re-render:');
server.respond();

console.log('\n3) Si el App se desmonta antes de tiempo, el cleanup lo protege:');
unmount();

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

=== Cargar los productos de Mercado al montar (useEffect) ===

1) Montaje: render inmediato (Loading...), luego el efecto pide al servidor:
  [render]  products = null -> "Loading..."
  [commit] DOM pintado; ahora corre el efecto
  [effect]  cargar los productos al montar
  [network] GET /products enviado al servidor

2) El servidor responde: setProducts dispara un re-render:
  [effect]  llegaron 5 productos -> setProducts
  [render]  products = 5 -> "Wireless Mouse, Mechanical Keyboard, USB-C Hub, Laptop Stand, Desk Lamp"

3) Si el App se desmonta antes de tiempo, el cleanup lo protege:
  [unmount] el App se va: corre su cleanup
  [cleanup] active = false

Lee la salida como la secuencia que es, que es exactamente la del módulo 6.

1) El montaje (L2, L5). El primer [render] muestra products = null -> "Loading...": la UI se pinta de inmediato, sin esperar la red, con el estado de carga. Solo después del render, el [commit] marca que el DOM ya está pintado y entonces corre el efecto ([effect] cargar los productos al montar), que manda la petición ([network] GET /products enviado). Ese orden —render primero, efecto después— es la regla de oro del módulo 6: el render es puro (no pide nada); el efecto corre tras el commit. Por eso el usuario ve Loading... al instante, aunque los datos tarden.

2) La respuesta (L5). Cuando el servidor responde, el callback comprueba su bandera (active sigue en true), reporta que llegaron 5 productos -> setProducts, y guarda los datos. Ese setProducts dispara un re-render (M3), y el nuevo [render] ya muestra products = 5 con los nombres del catálogo. El ciclo completo del patrón de fetch: null → pedir → respuesta → datos.

3) El cleanup (L4, L6). Modelamos un desmontaje (el usuario se va de la página, digamos): el [unmount] corre el cleanup del efecto, que pone active = false. ¿Por qué importa? Porque si la respuesta hubiera llegado después de este desmontaje, el callback habría visto active === false y habría descartado el resultado ([ignore] respuesta descartada) en vez de llamar setProducts en un App que ya no existe. El cleanup es la cortesía de cancelar el pedido cuando ya desocupaste la casa. Aquí lo desmontamos con la respuesta ya aplicada, así que solo ves la bandera bajarse; pero es la misma protección que en el módulo 6 descartaba respuestas obsoletas en una búsqueda rápida.

Con esto, el andamiaje de la lección 2 deja de tener datos de utilería: el App ahora trae los suyos, de forma asíncrona, con su estado de carga y su protección. La corriente está conectada.

Profundización: el patrón de carga, y sus fronteras

null no es [], y la diferencia es la UI. Arrancar products en null te deja distinguir tres situaciones que [] confunde: "todavía cargando" (nullLoading...), "cargó y hay productos" ([...] → la lista), y "cargó y no hay ninguno" ([] → un "no hay productos" honesto). Si arrancaras en [], no podrías diferenciar "esperando" de "vacío de verdad", y mostrarías una lista vacía sin que el usuario sepa si debe esperar o si la tienda está desierta. El null inicial es lo que hace posible un Loading... fiable.

El render no pide; el efecto sí. La regla que sostiene todo esto: el cuerpo del componente (el render) debe ser puro —solo describe la UI a partir del estado, sin efectos secundarios—. Pedir datos es un efecto secundario (toca la red), así que no puede ir en el render: iría en cada render, disparando peticiones sin control. Va en useEffect, que React corre después del commit, una vez (con []). Separar "describir" de "sincronizar con el afuera" es la lección central del módulo 6, y el storefront la respeta al pie de la letra.

El cleanup no es opcional, aunque "funcione" sin él. En desarrollo, con red rápida y sin desmontajes, un efecto de carga sin cleanup parece funcionar. El problema aparece en los bordes: el usuario navega fuera antes de que llegue la respuesta, o —en React con StrictMode— el efecto corre dos veces en desarrollo para exponer justo esta clase de bugs. Sin la bandera active, el callback intentaría setProducts sobre un componente desmontado, o una respuesta vieja pisaría una nueva. El cleanup de tres líneas cierra ese hueco. Costumbre dura: todo efecto que hace fetch lleva su cleanup.

Dónde termina el useEffect crudo (la frontera). Este patrón —useState(null) + useEffect + cleanup— es correcto, pero es crudo: en una app real, además querrías manejar errores, reintentos, caché, evitar recargar lo que ya tienes, y sincronizar con el servidor. Escribir todo eso a mano en cada componente es repetitivo y fácil de equivocar. Por eso en producción el data fetching suele vivir en una librería (React Query, SWR → guía frontend-state-and-data) o en el servidor (Server Components, SSR → guía nextjs-app-router), que resuelven carga/error/caché/race por ti. Aquí lo hacemos crudo a propósito: es el fundamento. Cuando uses una librería, sabrás qué te está resolviendo, porque lo escribiste a mano primero.

Errores comunes

Pedir los datos en el cuerpo del render. Qué pasa: se llama fetchProducts() directamente en el cuerpo del App (fuera de useEffect), "porque ahí es donde se necesitan". Cada render dispara una petición nueva, que al responder hace setProducts, que re-renderiza, que dispara otra petición: un bucle infinito de red. Por qué pasa: se confunde "necesito los datos aquí" con "pido los datos aquí". Cómo detectarlo: la pestaña de red se llena de peticiones idénticas sin parar. Cómo corregirlo: el fetch va en useEffect(() => { ... }, []), que corre después del render y una sola vez. El render solo describe la UI a partir del estado; pedir es un efecto.

Inicializar products en [] en vez de null. Qué pasa: useState([]) y no se puede distinguir "cargando" de "sin resultados"; el usuario ve una lista vacía sin saber si esperar. Por qué pasa: parece más simple arrancar con un array. Cómo detectarlo: no hay forma fiable de mostrar Loading.... Cómo corregirlo: arranca en null (cargando) y muestra Loading... mientras products === null; cuando la respuesta llega —aunque sea []—, ya sabes que cargó. Reserva [] para "cargó y está vacío".

Olvidar el cleanup del efecto de carga. Qué pasa: el efecto hace el fetch pero no devuelve nada; si el usuario se va antes de la respuesta, React advierte por un setState en un componente desmontado (o, con StrictMode y una búsqueda rápida, una respuesta vieja pisa una nueva). Por qué pasa: sin cleanup "funciona" en el camino feliz. Cómo detectarlo: advertencias intermitentes, o datos que parpadean al navegar rápido. Cómo corregirlo: la bandera active en el cleanup (let active = true; ...; return () => { active = false; }), y guardar solo if (active). En un efecto que toca la red, el cleanup no es opcional.

Ejercicios

Ejercicio 1 — Agrega el estado de error. El App de esta lección no maneja el caso de que el servidor falle. Reescribe el efecto y el render para manejar los tres estados —cargando, datos, error— sin romper el cleanup.

Ver solución
function App() {
  const [products, setProducts] = useState(null); // null = cargando
  const [error, setError] = useState(null);

  useEffect(() => {
    let active = true;
    fetchProducts()
      .then((data) => { if (active) setProducts(data); })
      .catch((err) => { if (active) setError(err); }); // el error tambien respeta la bandera
    return () => { active = false; };
  }, []);

  if (error) return <p className="error">Something went wrong.</p>;
  if (products === null) return <p className="loading">Loading...</p>;
  return (
    <main className="storefront">
      <SearchBar query="" />
      <ProductList products={products} />
      <Cart items={[]} />
    </main>
  );
}

Claves: (1) el .catch guarda el error, pero también dentro de if (active) —una respuesta de error obsoleta tampoco debe aplicarse—; (2) el render maneja las tres ramas en orden: error primero, cargando (null) después, datos al final. La bandera active protege las dos ramas (datos y error) por igual. En producción, este manejo de carga/error/caché es justo lo que una librería de datos te da hecho.

Ejercicio 2 — Traza el orden. Sin correr nada, escribe el orden en que ocurren estos cinco eventos cuando el App se monta y el servidor responde: (a) setProducts(data); (b) el primer render con Loading...; (c) el efecto manda la petición; (d) el commit (DOM pintado); (e) el segundo render con el catálogo. Justifica por qué el Loading... va antes que la petición.

Ver solución

El orden es: (b) → (d) → (c) → (a) → (e).

  1. (b) El primer render corre con products = null → pinta Loading....
  2. (d) El commit: React aplica ese render al DOM.
  3. (c) Después del commit corre el efecto, que manda la petición al servidor.
  4. (a) Cuando la respuesta llega, el callback hace setProducts(data).
  5. (e) Ese setProducts dispara el segundo render, ahora con los productos.

El Loading... (b) va antes que la petición (c) porque el render es puro y corre primero, mientras que el efecto corre después del commit. React siempre pinta lo que puede de inmediato (el estado de carga) y sincroniza con el afuera (la red) en un segundo momento. Por eso el usuario nunca ve una pantalla en blanco: ve Loading... al instante.

Ejercicio 3 — ¿Efecto o no? Para cada requisito del storefront, di si lleva un useEffect o no, y por qué: (a) cargar los products del servidor al montar; (b) calcular el total del carrito a partir de sus líneas; (c) filtrar la lista visible según el query (buscando en el cliente sobre los productos ya cargados); (d) volver a pedir los productos cuando cambia una categoría seleccionada que se manda al servidor.

Ver solución
  • (a) Efecto. Pedir al servidor toca la red (sistema externo) → useEffect con [] (solo al montar). Es esta lección.
  • (b) No. El total es una derivación (cartTotal(items)): se computa en el render a partir de datos que ya tienes. Nada de efecto (M5).
  • (c) No. Filtrar en el cliente sobre products ya cargados es una derivación en el render (getVisibleProducts), no toca la red. Es la lección 4, sin efecto (M5).
  • (d) Efecto. Si la categoría se manda al servidor como parte de la petición, entonces re-pedir toca la red → useEffect, y la categoría entra al array de dependencias ([category]) para re-pedir cuando cambie (M6, L3).

La regla del módulo 6: un efecto es para sincronizar con un sistema externo (la red). Transformar datos que ya tienes es derivación, sin efecto. El mismo requisito ("filtrar") lleva efecto o no según dónde ocurra: en el servidor sí, en el cliente no.

Resumen y siguiente paso

En esta lección le conectaste al storefront su carga de datos: el App ahora pide sus products al montarse con un useEffect (M6). Viste el patrón completo —useState(null) para el estado de carga (Loading...), el fetch dentro del efecto porque tocar la red es un efecto secundario, [] para correr solo al montar, y el cleanup con la bandera active que protege si el componente se desmonta—. Lo anclaste con encender la casa el día de la mudanza (entras, mandas conectar, esperas a oscuras, prende la luz). Y lo ejecutaste con un mini-runtime: la secuencia render → commit → efecto → respuesta → re-render, con el Loading... apareciendo antes que la petición, y el cleanup bajando la bandera al desmontar.

Antes de avanzar deberías poder: escribir un efecto de carga con useState(null), [] y cleanup; explicar por qué el fetch va en el efecto y no en el render; distinguir "cargando" (null) de "vacío" ([]); y ubicar la frontera —cuándo dejar el fetch a una librería o al servidor—.

La lección 4 le da al catálogo su interactividad de búsqueda. La SearchBar, hasta ahora inerte, se volverá un input controlado (value + onChange) cuyo texto es estado (query, M3-M4), y la lista visible se derivará de los products cargados y el query con getVisibleProducts —filtrar por la búsqueda y ordenar por precio, computado en cada render, sin guardarse— (M5). Ejecutarás una sesión de tecleo donde cada cambio de query re-deriva la lista, incluido el "sin resultados". El catálogo, que hoy solo se carga, empezará a responder.

Recursos

  • React, "Synchronizing with Effects" — react.dev/learn/synchronizing-with-effects. El repaso integral de useEffect: sincronizar con sistemas externos, el array de dependencias, el cleanup, y el patrón de fetch con la bandera de cancelación —justo la carga de esta lección—. En inglés.
  • React, "You Might Not Need an Effect" — react.dev/learn/you-might-not-need-an-effect. El criterio para distinguir qué va en efecto (tocar la red) y qué en derivación (transformar datos que ya tienes) —clave para no meter un efecto donde va un getVisibleProducts—. En inglés.
  • React, "Fetching data" (en "You Might Not Need an Effect") — react.dev/learn/you-might-not-need-an-effect#fetching-data. Por qué en producción el fetch suele ir en una librería o en el servidor, no en useEffect crudo; la frontera con frontend-state-and-data y nextjs-app-router. En inglés.
  • MDN, "Using the Fetch API" — developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch. Cómo se pide datos a un servidor en el navegador (lo que fetchProducts representa); la API real que usarías dentro del efecto. En inglés.