Módulo 6: Effects And Side Effects

Mini-proyecto: carga los productos de Mercado

Descripción

Es hora de juntar el módulo en un solo lugar. Hasta ahora, los products de Mercado eran un array fijo escrito en el código: la tienda funcionaba, pero con datos de mentira. En este mini-proyecto le conectas la red: el App de Mercado, al montarse, pide sus productos con un efecto y los muestra cuando llegan; y la búsqueda se hace en el servidor —cada cambio de query dispara una nueva petición—, con un cleanup race-safe que descarta las respuestas obsoletas cuando llegan fuera de orden. Es el módulo entero trabajando junto: el efecto que sincroniza con lo externo (L2), el array de dependencias (L3), el cleanup (L4), el patrón de fetch (L5), la protección contra la race condition (L6), y el criterio de no meter un efecto donde va una derivación (L7).

Conexión con el módulo. Esta es la lección de integración, y también la que marca la frontera con lo que sigue. Aquí el App es dueño de los products cargados y del query; cómo bajan a los hijos (ProductList, SearchBar) y cómo se comparte el cart es levantar el estado, el módulo 7. Y todo el manejo de carga/error/caché/race que ves a mano aquí es lo que una librería de datos (React Query, SWR) resuelve por ti —la guía de estado y datos—; el fetch en el servidor (sin useEffect, sin Loading...) es la guía de Next.js. Este proyecto es "fetch en el cliente con useEffect, bien hecho"; los tres caminos se abren desde aquí.

Qué vas a construir

Dos efectos en el App, cada uno ilustrando una pieza del módulo:

  1. Carga inicial / búsqueda en el servidor. Un useEffect con [query] que le pide al servidor los productos que coinciden con la búsqueda actual. Con query = "" (al montar) trae todos: es la carga inicial. Cuando el usuario escribe, query cambia y el efecto re-pide con el nuevo término. El estado products arranca en null (Loading...) hasta que llega la primera respuesta.
  2. La protección race-safe. Ese mismo efecto devuelve un cleanup con una bandera ignore. Si el usuario escribe rápido y dos respuestas llegan fuera de orden, el cleanup de la búsqueda vieja la marca como obsoleta, y su respuesta se descarta al llegar. Así el estado final siempre refleja la última búsqueda, no la que llegó última por la red.

Nota lo que no hacemos, aplicando la lección 7: no guardamos una lista "filtrada" en otro estado ni la sincronizamos con un efecto. Como la búsqueda es en el servidor, products ya viene filtrado del servidor; no hay nada que derivar de más. (Si la búsqueda fuera en el cliente —filtrar un array local por query—, eso sería una derivación en el render, módulo 5, y no llevaría efecto. Aquí es en el servidor, así que sí es un efecto: pedir es tocar la red.)

El código React real

Así se escribe el App de Mercado con la carga y la búsqueda en el servidor. Es la síntesis del módulo:

import { useEffect, useState } from 'react';

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

  useEffect(() => {
    let ignore = false;                             // esta ejecucion tiene SU bandera (L6)
    searchProducts(query).then((data) => {
      if (!ignore) setProducts(data);               // solo aplico si sigo vigente
    });
    return () => { ignore = true; };                // cleanup: invalido esta busqueda (L4, L6)
  }, [query]);                                       // re-busca cuando query cambia (L3)

  return (
    <div className="storefront">
      <SearchBar value={query} onChange={setQuery} /> {/* input controlado, modulo 4 */}
      {products === null
        ? <p>Loading...</p>
        : <ProductList products={products} />}
    </div>
  );
}

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

  • useState(null) para products: null significa "aún no llegó la respuesta" → Loading... (L5).
  • searchProducts(query) dentro del efecto: pedir al servidor es tocar la red, un sistema externo → va en useEffect, no en el render (L2).
  • [query] como dependencia: el efecto usa query, así que declara que depende de él; re-pide cuando query cambia (L3). Poner [] aquí sería mentir, y la búsqueda se quedaría clavada en el primer valor.
  • let ignore = false + if (!ignore) + return () => { ignore = true; }: la protección race-safe. Cada búsqueda tiene su propia bandera; el cleanup de la vieja la invalida cuando query cambia (L4, L6).

Y fíjate en lo que no está: no hay un useEffect que "sincronice" una lista filtrada, ni un estado visibleProducts de más. El servidor filtra; el App solo guarda lo que llega (L7).

Ejemplo trabajado: la sesión completa, ejecutada

Vamos a correr una sesión de uso realista y a verla de punta a punta. Modelamos un "servidor" determinista que responde búsquedas en el orden que le pidamos, y un runtime con estado, efectos y cleanup. La sesión: (1) el App se monta y hace la carga inicial (query = ""); (2) el usuario escribe k y enseguida ke —dos búsquedas en el servidor—; (3) las respuestas llegan fuera de orden (ke rápida, k lenta), y el cleanup descarta la obsoleta:

// "Servidor" simulado: responde busquedas en el orden que le pidamos (determinista).
const ALL = [
  { id: 'p1', name: 'Wireless Mouse', priceCents: 2599 },
  { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
  { id: 'p3', name: 'USB-C Hub', priceCents: 3450 },
  { id: 'p4', name: 'Kensington Lock', priceCents: 1999 },
];
function makeServer() {
  const inbox = [];
  return {
    search(query, onResult) {
      const q = query.toLowerCase();
      const data = ALL.filter((p) => p.name.toLowerCase().includes(q));
      inbox.push({ query, onResult, data });
    },
    deliver(query) {
      const i = inbox.findIndex((r) => r.query === query);
      const { onResult, data } = inbox.splice(i, 1)[0];
      onResult(data);
    },
  };
}

// (runtime con useState + useEffect + cleanup, como en las lecciones 4-6)

const server = makeServer();
let ui;
function App() {
  const [query, setQuery] = useState('');
  const [products, setProducts] = useState(null); // null = cargando

  useEffect(() => {
    let ignore = false;
    console.log(`  [setup]   search(${JSON.stringify(query)}) enviada al servidor`);
    server.search(query, (data) => {
      if (ignore) { console.log(`  [ignore]  respuesta de ${JSON.stringify(query)} descartada (obsoleta)`); return; }
      console.log(`  [result]  ${JSON.stringify(query)} -> setProducts(${data.length} items)`);
      setProducts(data);
    });
    return () => { ignore = true; console.log(`  [cleanup] ignore=true para ${JSON.stringify(query)}`); };
  }, [query]);

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

render = () => { cursor = 0; ecur = 0; ui = App(); commit(); };

console.log('=== Storefront de Mercado: carga + busqueda en servidor (race-safe) ===\n');
console.log('1) Montaje: carga inicial (query = ""):');
render();
server.deliver('');          // llega la carga inicial

console.log('\n2) El usuario escribe "k" y enseguida "ke" (busquedas en servidor):');
ui.setQuery('k');            // dispara search('k'); cleanup de '' corrio antes
ui.setQuery('ke');           // dispara search('ke'); cleanup de 'k' -> ignore

console.log('\n3) Las respuestas llegan FUERA de orden: "ke" (rapida) y luego "k" (lenta):');
server.deliver('ke');        // la nueva llega y se aplica
server.deliver('k');         // la vieja llega, pero fue ignorada por su cleanup

console.log(`\nEstado final: query=${JSON.stringify(cells[0])}  productos=[${cells[1].map((p) => p.name).join(', ')}]`);

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

=== Storefront de Mercado: carga + busqueda en servidor (race-safe) ===

1) Montaje: carga inicial (query = ""):
  [render]  query=""  ->  Loading...
  [setup]   search("") enviada al servidor
  [result]  "" -> setProducts(4 items)
  [render]  query=""  ->  Wireless Mouse, Mechanical Keyboard, USB-C Hub, Kensington Lock

2) El usuario escribe "k" y enseguida "ke" (busquedas en servidor):
  [render]  query="k"  ->  Wireless Mouse, Mechanical Keyboard, USB-C Hub, Kensington Lock
  [cleanup] ignore=true para ""
  [setup]   search("k") enviada al servidor
  [render]  query="ke"  ->  Wireless Mouse, Mechanical Keyboard, USB-C Hub, Kensington Lock
  [cleanup] ignore=true para "k"
  [setup]   search("ke") enviada al servidor

3) Las respuestas llegan FUERA de orden: "ke" (rapida) y luego "k" (lenta):
  [result]  "ke" -> setProducts(2 items)
  [render]  query="ke"  ->  Mechanical Keyboard, Kensington Lock
  [ignore]  respuesta de "k" descartada (obsoleta)

Estado final: query="ke"  productos=[Mechanical Keyboard, Kensington Lock]

Esta salida es el módulo entero en una sesión. Vamos por sus tres partes.

1) La carga inicial (L5). El primer [render] muestra Loading...: products arranca en null, la UI se pinta de inmediato sin esperar la red. Tras el commit corre el [setup] con query = "" y manda search("") al servidor. Cuando el servidor responde ([result]), setProducts guarda los 4 productos, y el [render] siguiente los muestra. Idéntico al patrón de la lección 5: null → pedir → respuesta → datos.

2) Dos búsquedas rápidas (L3, L4). El usuario escribe k: setQuery('k') re-renderiza ([render] query="k", aún con los 4 productos viejos porque la nueva respuesta no llegó), y como query cambió, el efecto se re-ejecuta: primero corre el [cleanup] ignore=true para "" (invalida la búsqueda de la carga inicial), y luego el [setup] search("k"). Enseguida el usuario escribe ke: otro re-render, y de nuevo [cleanup] ignore=true para "k" (invalida la búsqueda de k) seguido de [setup] search("ke"). Fíjate en el patrón que mediste en la lección 4: el cleanup de la búsqueda anterior corre antes del setup de la nueva. En este punto hay dos búsquedas en vuelo (k y ke), y las dos anteriores ("" y k) ya fueron marcadas como ignorables.

3) Las respuestas fuera de orden (L6). Aquí está la prueba de fuego. Llega primero la respuesta de ke (la rápida): su bandera ignore sigue en false (nadie la invalidó), así que se aplica —setProducts(2 items)— y el [render] muestra Mechanical Keyboard, Kensington Lock (los dos productos que contienen "ke"). Luego llega la respuesta de k (la lenta): pero su cleanup ya puso ignore = true cuando el usuario pasó a ke, así que el callback la descarta ([ignore] respuesta de "k" descartada (obsoleta)) en vez de pisar el resultado bueno.

El estado final es coherente: query = "ke" y productos = [Mechanical Keyboard, Kensington Lock] —exactamente los resultados de la última búsqueda que el usuario hizo—. Sin el cleanup, la respuesta lenta de k habría llegado última y pisado la de ke, dejando en pantalla los resultados de una búsqueda que el usuario ya había abandonado. El cleanup de tres líneas es lo que hace que la sesión termine correcta.

El ciclo del proyecto, en un diagrama

Así fluye la sesión completa —render, efecto, cleanup, respuesta— a lo largo del tiempo:

flowchart TD
    Mount["Montaje: query=''<br/>products=null (Loading...)"] --> S0["setup: search('')"]
    S0 --> R0["respuesta '' -> setProducts(4)<br/>render: 4 productos"]
    R0 --> T1["usuario escribe 'k'<br/>cleanup('') + setup: search('k')"]
    T1 --> T2["usuario escribe 'ke'<br/>cleanup('k') -> ignore + setup: search('ke')"]
    T2 --> Race{"respuestas fuera de orden"}
    Race -->|"'ke' llega 1ro (ignore=false)"| Apply["setProducts(2)<br/>render: Keyboard, Kensington"]
    Race -->|"'k' llega 2do (ignore=true)"| Drop["descartada (obsoleta)"]
    Apply --> Final["Estado final: query='ke'<br/>productos coherentes"]
    Drop --> Final

Las dos ramas de la carrera confluyen en un estado final coherente: la respuesta vigente (ke) se aplica, la obsoleta (k) se descarta. Esa es la garantía del cleanup.

Checklist del proyecto

Antes de dar por terminado el mini-proyecto, verifica que aplicaste cada concepto:

  • Sincronizar, no transformar (L2): ¿el efecto toca un sistema externo (la red)? Sí —por eso es un efecto y no una derivación—.
  • Array de dependencias (L3): ¿el efecto declara [query] porque usa query? ¿No mentiste con []?
  • Cleanup (L4): ¿el efecto devuelve una función que invalida la búsqueda vieja?
  • Fetch (L5): ¿products arranca en null (cargando) y se llena con la respuesta? ¿La UI muestra Loading... mientras tanto?
  • Race-safe (L6): ¿la bandera ignore descarta la respuesta obsoleta si llega fuera de orden?
  • No sobra ningún efecto (L7): ¿no guardaste una lista "filtrada" de más en estado? (Aquí el servidor filtra; si filtraras en el cliente, sería una derivación en el render, sin efecto.)

Si las seis casillas están marcadas, integraste el módulo.

Errores comunes

Hacer la búsqueda en el cliente pero meterla en un efecto. Qué pasa: en vez de pedirle al servidor, se filtra un array local por query dentro de un useEffect y se guarda en estado. Por qué pasa: se confunde "cuando query cambia, actualiza la lista" con una sincronización. Cómo detectarlo: el efecto no toca la red —solo filtra un array que ya tienes— y guarda el resultado en otro estado. Cómo corregirlo: filtrar en el cliente es una derivación (módulo 5): const visible = products.filter(...) en el render, sin efecto. El efecto es solo para la búsqueda en el servidor (que sí toca la red). Distingue dónde ocurre el filtrado.

Olvidar el cleanup en la búsqueda en el servidor. Qué pasa: el efecto [query] hace la búsqueda pero no devuelve cleanup; al escribir rápido, a veces se ven resultados de una búsqueda anterior. Por qué pasa: el patrón "funciona" en desarrollo con red rápida. Cómo detectarlo: bug intermitente que aparece al teclear rápido; en la sesión ejecutada, sin cleanup la respuesta de k habría pisado la de ke. Cómo corregirlo: la bandera ignore en el cleanup (L6). En una búsqueda que re-pide con cada tecla, el cleanup no es opcional.

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

Ejercicios

Ejercicio 1 — Predice la sesión. Sin correr nada, predice el estado final (query y qué productos) si la sesión fuera: montar, el usuario escribe mouse, y la única respuesta que llega es la de mouse. ¿Cuántas búsquedas se envían al servidor y cuántos cleanups corren?

Ver solución
  • Estado final: query = "mouse", productos = [Wireless Mouse] (el único que contiene "mouse").
  • Búsquedas enviadas: 2 —search("") en el montaje y search("mouse") cuando el usuario escribe—. (Si el usuario escribiera letra por letra habría más; el enunciado dice que escribe mouse como un solo cambio de query.)
  • Cleanups que corren: 1 —el de la búsqueda "", que corre cuando query cambia a "mouse" (invalidando la carga inicial antes del nuevo setup)—.

Como solo llega la respuesta de mouse (la de "" nunca se entrega, o si se entrega, su ignore ya es true), el estado final refleja mouse. La sesión es correcta con o sin race porque solo se aplica una respuesta vigente.

Ejercicio 2 — Agrega el estado de error. El App del proyecto no maneja el caso de que el servidor falle. Reescribe el efecto para manejar los tres estados —cargando, datos, error— sin romper la protección race-safe.

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

  useEffect(() => {
    let ignore = false;
    setError(null);                       // limpio el error al empezar una busqueda nueva
    searchProducts(query)
      .then((data) => { if (!ignore) setProducts(data); })
      .catch((err) => { if (!ignore) setError(err); }); // el error tambien respeta ignore
    return () => { ignore = true; };
  }, [query]);

  if (error) return <p>Something went wrong.</p>;
  if (products === null) return <p>Loading...</p>;
  return <ProductList products={products} />;
}

Claves: (1) el .catch guarda el error, pero también dentro de if (!ignore) —una respuesta de error obsoleta tampoco debe aplicarse—; (2) setError(null) al inicio de cada búsqueda, para que un error viejo no persista al re-buscar; (3) el render maneja las tres ramas: error, cargando (null), datos. La bandera ignore protege las dos ramas (datos y error) por igual.

Ejercicio 3 — ¿Servidor o cliente? Mercado quiere agregar un filtro "solo disponibles" (onlyInStock). Discute: si el filtro se aplica en el cliente sobre los products ya cargados, ¿lleva efecto o derivación? ¿Y si el filtro se manda al servidor como parte de la búsqueda? Escribe el código de la versión en el cliente.

Ver solución
  • En el cliente: onlyInStock filtra los products que ya tienes en memoria. Eso es una derivación (módulo 5), sin efecto: se computa en el render.
  • En el servidor: si onlyInStock se manda al servidor como parte de la petición (searchProducts(query, onlyInStock)), entonces sí es parte del efecto —tocas la red—, y onlyInStock debe entrar al array de dependencias: [query, onlyInStock], para re-pedir cuando cambie.

Versión en el cliente (derivación, sin efecto):

function App() {
  const [query, setQuery] = useState('');
  const [onlyInStock, setOnlyInStock] = useState(false);
  const [products, setProducts] = useState(null);

  useEffect(() => {
    let ignore = false;
    searchProducts(query).then((data) => { if (!ignore) setProducts(data); });
    return () => { ignore = true; };
  }, [query]); // el fetch depende solo de query (la busqueda es en el servidor)

  // el filtro "solo disponibles" es una DERIVACION en el cliente: sin efecto (L7)
  const visible = products === null
    ? null
    : products.filter((p) => (onlyInStock ? p.inStock : true));

  if (visible === null) return <p>Loading...</p>;
  return <ProductList products={visible} />;
}

La lección: el mismo requisito ("filtrar por disponibilidad") lleva efecto o no según dónde se aplique. En el servidor toca la red → efecto + dependencia. En el cliente es un cálculo sobre datos que ya tienes → derivación en el render. Distinguir las dos es el criterio central del módulo.

Resumen y siguiente paso

En este mini-proyecto integraste el módulo dándole al storefront de Mercado su conexión con la red. Escribiste el código React real del App con un efecto que hace la carga inicial y la búsqueda en el servidor ([query], products en null para Loading...), protegido con un cleanup race-safe (la bandera ignore). Y lo ejecutaste en una sesión completa: el montaje cargó los 4 productos; el usuario escribió k y ke disparando dos búsquedas (con el cleanup de la vieja corriendo antes del setup de la nueva); las respuestas llegaron fuera de orden (ke primero, k después); y el cleanup descartó la obsoleta, dejando un estado final coherente (query = "ke", con los productos de "ke"). Repasaste el ciclo en un diagrama y el checklist de las seis piezas del módulo.

Con esto dominas useEffect: sabes qué es un side effect y por qué el render puro no puede tener ninguno; cuándo un efecto vuelve a correr (el array de dependencias); cómo deshacer lo que montó (el cleanup); cómo pedir datos y protegerte de la race condition y el doble efecto de StrictMode; y —tan importante como todo lo anterior— cuándo no usar un efecto (derivar en el render, o poner la lógica en un event handler). El criterio que llevas es tan valioso como la mecánica.

El módulo 7 —levantar el estado y composición— responde la pregunta que este proyecto dejó abierta: el App es dueño de los products y del query, pero cómo ese estado baja a los hijos (SearchBar, ProductList) y cómo se comparte el cart entre componentes que no son padre-hijo (el ProductCard que agrega y el Cart que muestra) es levantar el estado. Y desde aquí se abren los dos caminos que este módulo delimitó: las librerías de datos (React Query, SWR) que resuelven el fetch crudo por ti → guía de estado y datos; y el fetch en el servidor (SSR, Server Components) → guía de Next.js. Ya tienes el useEffect crudo; ahora sabrás cuándo dejar que otra herramienta lo maneje.

Recursos

  • React, "Synchronizing with Effects" — react.dev/learn/synchronizing-with-effects. El repaso integral de useEffect (sincronización, dependencias, cleanup, fetch con ignore); útil para consolidar todo el módulo antes del proyecto. En inglés.
  • React, "You Might Not Need an Effect" — react.dev/learn/you-might-not-need-an-effect. El criterio para decidir qué va en efecto y qué en derivación/handler; lo aplicas al distinguir búsqueda en servidor (efecto) de filtro en cliente (derivación). En inglés.
  • React, "Sharing State Between Components" — react.dev/learn/sharing-state-between-components. El puente al módulo 7: cuando el estado que un componente posee debe compartirse con otros. En inglés.
  • React, "You Might Not Need an Effect" (sección "Fetching data") y la doc de frameworks — react.dev/learn/you-might-not-need-an-effect. Por qué en producción el data fetching suele ir en una librería o en el servidor, no en useEffect crudo; la frontera con frontend-state-and-data y nextjs. En inglés.