Módulo 6: Effects And Side Effects

Trampas del fetch: race conditions, cleanup y StrictMode

Descripción

El patrón de fetch de la lección 5 funciona cuando pides datos una vez. Pero en cuanto los pides repetidas veces —una búsqueda que consulta al servidor con cada tecla, un detalle que se recarga al cambiar de producto— aparece una trampa que no se ve a simple vista y que arruina apps de verdad: la race condition. Dos peticiones salen casi juntas, y como la red no garantiza el orden de llegada, la respuesta vieja puede llegar después de la nueva y pisarla: el usuario escribió "mouse" pero ve los resultados de "mou". Esta lección mide esa carrera con dos "fetch" que resuelven fuera de orden, muestra cómo el cleanup de la lección 4 —con una bandera ignore— descarta la respuesta obsoleta, y explica el doble efecto de StrictMode en desarrollo, que existe precisamente para destapar los efectos a los que les falta esa protección. Y cierra con el porqué de React Query y SWR.

Conexión con el módulo. Es la síntesis de las lecciones 4 y 5: el fetch (5) protegido con cleanup (4). Aquí el cleanup deja de ser "cerrar una conexión" y se vuelve algo más sutil —invalidar una respuesta en vuelo—, que es su uso más importante en data fetching. Y es la lección que justifica la frontera del módulo: cuando veas cuánto cuidado exige el fetch crudo, entenderás por qué en producción se usa una librería.

Una analogía: dos pedidos por correo que se cruzan

Retomemos el pedido por correo de la lección 5, pero ahora pides dos cosas seguidas. A las 10:00 pides el libro A. A las 10:01 cambias de opinión y pides el libro B (el que de verdad quieres). El problema: el correo no garantiza que lleguen en el orden que los pediste. Si A viajaba por una ruta lenta y B por una rápida, B llega primero (lo pones en tu mesa) y A llega después —y si eres descuidado, lo pones en la mesa encima de B, y terminas con el libro A, el que ya no querías—.

Eso es una race condition: dos respuestas "compiten" por llegar, y la que llega última gana, aunque sea la vieja. La solución no es acelerar el correo (no controlas la red); es poner una regla al recibir: "cuando cambié de opinión y pedí B, marco el pedido A como cancelado; si A llega después, lo ignoro". Esa marca de "cancelado" es el cleanup con la bandera ignore. No cancelas la petición en la red (ya salió); cancelas tu interés en su respuesta: cuando llegue, la tiras. Guarda la imagen: no importa el orden de llegada si marcaste como obsoleto todo lo que pediste antes del último cambio.

Ejemplo trabajado: la race condition, con y sin cleanup

Modelemos el caso exacto de Mercado: el usuario escribe a, y enseguida ab. Cada cambio de query dispara una búsqueda en el servidor. La búsqueda de a resulta lenta y la de ab rápida, así que ab responde primero y a después. El estado final correcto debe ser el de ab (lo último que el usuario escribió). El componente como lo escribirás en React —con y sin la protección— es:

// CON cleanup (correcto): la bandera ignore descarta la respuesta obsoleta.
useEffect(() => {
  let ignore = false;
  fetchResults(query).then((data) => {
    if (!ignore) setResults(data); // solo aplico si este efecto sigue vigente
  });
  return () => { ignore = true; };  // cleanup: al cambiar query, invalido esta respuesta
}, [query]);

Para medir la carrera de forma determinista (sin depender de la red real), usamos una "red" simulada donde nosotros controlamos el orden de entrega con deliver(query). Corremos las dos versiones —sin cleanup y con cleanup— entregando siempre ab primero y a después:

// "Red" simulada: guarda las respuestas y las entrega en el orden que le pidamos.
function makeNetwork() {
  const inbox = [];
  return {
    send(query, onResult) { inbox.push({ query, onResult }); },
    deliver(query) {
      const idx = inbox.findIndex((r) => r.query === query);
      const { onResult } = inbox.splice(idx, 1)[0];
      onResult(`results-for-${query}`);
    },
  };
}

// ── (A) SIN cleanup ──
function withoutCleanup() {
  const net = makeNetwork();
  let results = null;
  const setResults = (v) => { results = v; };
  net.send('a', (data) => { console.log(`    respuesta de 'a'  -> setResults(${data})`); setResults(data); });
  net.send('ab', (data) => { console.log(`    respuesta de 'ab' -> setResults(${data})`); setResults(data); });
  console.log("  (A) SIN cleanup. Orden de llegada: primero ab (rapida), luego a (lenta):");
  net.deliver('ab'); // llega la nueva primero
  net.deliver('a');  // llega la vieja despues y PISA a la nueva
  console.log(`  -> estado final: results = ${results}   (el usuario ve 'a', escribio 'ab': MAL)\n`);
  return results;
}

// ── (B) CON cleanup (bandera ignore) ──
function withCleanup() {
  const net = makeNetwork();
  let results = null;
  const setResults = (v) => { results = v; };
  let ignoreA = false;
  net.send('a', (data) => {
    if (ignoreA) { console.log(`    respuesta de 'a'  -> IGNORADA (ignore=true)`); return; }
    console.log(`    respuesta de 'a'  -> setResults(${data})`); setResults(data);
  });
  const cleanupA = () => { ignoreA = true; };
  cleanupA(); // al escribir 'ab', el cleanup del efecto de 'a' corre ANTES del nuevo setup
  let ignoreB = false;
  net.send('ab', (data) => {
    if (ignoreB) { console.log(`    respuesta de 'ab' -> IGNORADA`); return; }
    console.log(`    respuesta de 'ab' -> setResults(${data})`); setResults(data);
  });
  console.log('  (B) CON cleanup. Mismo orden de llegada: ab primero, a despues:');
  net.deliver('ab'); // la nueva llega y se aplica
  net.deliver('a');  // la vieja llega, pero ignore=true -> se descarta
  console.log(`  -> estado final: results = ${results}   (el usuario ve 'ab': BIEN)\n`);
  return results;
}

console.log('=== Race condition: dos fetch que resuelven fuera de orden ===\n');
const a = withoutCleanup();
const b = withCleanup();
console.log('Resumen:');
console.log(`  sin cleanup -> ${a}   (obsoleto gana)`);
console.log(`  con cleanup -> ${b}  (obsoleto descartado)`);

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

=== Race condition: dos fetch que resuelven fuera de orden ===

  (A) SIN cleanup. Orden de llegada: primero ab (rapida), luego a (lenta):
    respuesta de 'ab' -> setResults(results-for-ab)
    respuesta de 'a'  -> setResults(results-for-a)
  -> estado final: results = results-for-a   (el usuario ve 'a', escribio 'ab': MAL)

  (B) CON cleanup. Mismo orden de llegada: ab primero, a despues:
    respuesta de 'ab' -> setResults(results-for-ab)
    respuesta de 'a'  -> IGNORADA (ignore=true)
  -> estado final: results = results-for-ab   (el usuario ve 'ab': BIEN)

Resumen:
  sin cleanup -> results-for-a   (obsoleto gana)
  con cleanup -> results-for-ab  (obsoleto descartado)

Compara las dos versiones, porque el orden de llegada es idéntico en ambas —ab primero, a después— y aun así el resultado es distinto.

(A) Sin cleanup. Llega ab y hace setResults(results-for-ab): por un instante, correcto. Pero luego llega a (la vieja, lenta) y hace setResults(results-for-a), pisando a la nueva. El estado final es results-for-a: el usuario escribió ab pero ve los resultados de a. Es el bug clásico de las barras de búsqueda: escribes rápido y de pronto ves resultados de una búsqueda anterior. Nadie lo pidió; es la carrera perdida.

(B) Con cleanup. La diferencia está en el cleanupA() que corre cuando el usuario escribe ab (el cleanup del efecto de a, que corre antes del setup de ab, tal como mediste en la lección 4). Ese cleanup pone ignoreA = true. Ahora, cuando llega ab, se aplica (setResults(results-for-ab)). Y cuando llega a después, su callback revisa la bandera: ignore es true, así que descarta la respuesta (IGNORADA) en vez de aplicarla. El estado final es results-for-ab: correcto. La petición de a igual viajó por la red (no la cancelamos), pero cancelamos nuestro interés en su respuesta.

El resumen lo deja crudo: mismo orden de llegada, resultado opuesto. Sin cleanup gana el obsoleto (results-for-a); con cleanup gana el vigente (results-for-ab). Esa bandera ignore de tres líneas es la diferencia entre una búsqueda correcta y una con bugs intermitentes imposibles de reproducir.

Profundización: por qué el ignore va en el cleanup, StrictMode, y las librerías

Por qué la bandera vive en el cleanup (y funciona). La clave es que cada ejecución del efecto tiene su propia variable ignore. Cuando el efecto corre para query = "a", crea un ignore = false que solo ve el callback de esa petición. Cuando query cambia a "ab", React corre el cleanup del efecto de "a" —que pone su ignore = true— y luego el nuevo efecto para "ab" crea otro ignore = false, independiente. Así, la respuesta de "a", cuando llega, mira su bandera (ahora true) y se descarta; la de "ab" mira la suya (false) y se aplica. Es el cleanup de la lección 4 usado no para "cerrar una conexión" sino para invalidar una respuesta en vuelo: el uso más importante del cleanup en data fetching.

StrictMode: el doble efecto de desarrollo. En desarrollo, si envuelves tu app en <StrictMode>, React monta cada componente, corre sus efectos, corre sus cleanups, y vuelve a montar —es decir, ejecuta cada efecto dos veces: setup → cleanup → setup—. Esto no pasa en producción; es una prueba deliberada. ¿Para qué? Para destaparte los efectos que no limpian bien. Un efecto con cleanup correcto sobrevive la prueba sin dejar nada colgando; uno sin cleanup deja el doble. Midámoslo:

function simulateStrictModeMount(effect) {
  const cleanup1 = effect();               // setup #1
  if (typeof cleanup1 === 'function') cleanup1(); // cleanup #1
  const cleanup2 = effect();               // setup #2 (el remonte)
  return cleanup2;
}

Corriendo un efecto que abre una conexión, con y sin cleanup, Qué esperar es:

=== StrictMode (dev): setup -> cleanup -> setup ===

CON cleanup (el effect cierra la conexion):
  setup   -> connect()     conexiones: 1
  cleanup -> disconnect()  conexiones: 0
  setup   -> connect()     conexiones: 1
  -> conexiones activas al final: 1  (correcto: 1)

SIN cleanup (el effect NO cierra nada):
  setup   -> connect()     conexiones: 1
  setup   -> connect()     conexiones: 2
  -> conexiones activas al final: 2  (bug: 2, el doble)

Con cleanup, la secuencia setup → cleanup → setup deja 1 conexión: el primer setup abre, el cleanup cierra, el segundo setup vuelve a abrir. Justo lo que debe quedar. Sin cleanup, los dos setup abren y nada cierra: quedan 2. StrictMode acaba de mostrarte, en desarrollo, el leak que en producción habrías tenido en cada re-montaje. La lección práctica: si ves tu efecto correr dos veces en desarrollo (dos peticiones, dos conexiones, dos logs), no es un bug de React ni algo que "arreglar" desactivando StrictMode —es React avisándote de que a tu efecto le falta cleanup, o de que no es idempotente—. La respuesta correcta es agregar el cleanup, no silenciar la advertencia.

Por qué StrictMode y la race apuntan a lo mismo. Los dos problemas —la race condition y el doble efecto— se resuelven con el mismo cleanup. Un efecto de fetch con la bandera ignore no solo gana la carrera de respuestas fuera de orden; también sobrevive el doble montaje de StrictMode sin aplicar dos veces la respuesta (el cleanup del primer montaje pone ignore = true, descartando la primera respuesta si llega tarde). El cleanup bien hecho es la respuesta a ambos.

Por qué existen React Query y SWR (aquí se ve el problema; la solución está en otra guía). Repasa lo que exige un fetch crudo correcto: estado de carga, estado de error, la bandera ignore para la race, la re-petición cuando cambia una dependencia, y todavía te falta caché (no volver a pedir lo mismo), deduplicación (dos componentes que piden lo mismo → una sola petición), reintentos, y revalidación al volver a la pestaña. Escribir y mantener todo eso a mano, en cada componente, es inviable y propenso a bugs. React Query y SWR encapsulan exactamente esto: la race condition, la caché, la dedup y lo demás vienen resueltos de fábrica. Por eso en producción casi nadie escribe el fetch crudo con useEffect. Este módulo te muestra el problema en carne viva para que entiendas qué compras cuando adoptas una de esas librerías; cómo usarlas es la guía de estado y datos (frontend-state-and-data).

Errores comunes

Fetch con dependencia cambiante sin la bandera ignore (race condition). Qué pasa: useEffect(() => { fetch(query).then(setResults); }, [query]) sin cleanup; al escribir rápido, a veces se muestran resultados de una búsqueda anterior. Por qué pasa: la red no garantiza el orden de llegada, y sin ignore la respuesta vieja pisa a la nueva. Cómo detectarlo: bug intermitente, difícil de reproducir, que aparece al escribir rápido o con red lenta. Cómo corregirlo: agrega la bandera en el cleanup: let ignore = false; en el setup, if (!ignore) setResults(data) en el callback, return () => { ignore = true; }; como cleanup.

Desactivar StrictMode para "callar" el doble efecto. Qué pasa: se ve el efecto correr dos veces en desarrollo (dos peticiones, dos logs) y se quita <StrictMode> para que "deje de pasar". Por qué pasa: se interpreta la advertencia como un bug de React. Cómo detectarlo: quitaste StrictMode y "se arregló", pero en producción sigues teniendo el problema en cada re-montaje. Cómo corregirlo: StrictMode no causa el bug, lo revela. La solución es hacer el efecto idempotente y con cleanup —que correr setup → cleanup → setup deje el mismo estado que un solo setup—. Deja StrictMode encendido; es tu red de seguridad.

Cancelar mal: creer que hay que abortar la petición en la red. Qué pasa: alguien intenta "cancelar" la petición vieja y se enreda, o cree que sin abortar la red no hay solución. Por qué pasa: se confunde "cancelar la petición" con "ignorar la respuesta". Cómo detectarlo: complejidad innecesaria alrededor de abortar. Cómo corregirlo: para la race condition basta con ignorar la respuesta obsoleta (la bandera ignore): la petición vieja llega igual, pero no la aplicas. (Abortar de verdad la red se puede con AbortController, y es una optimización, no lo esencial; la doc lo cubre.)

Ejercicios

Ejercicio 1 — Agrega la protección. Este efecto de búsqueda sufre la race condition. Agrégale la bandera ignore en el cleanup:

useEffect(() => {
  fetchResults(query).then((data) => setResults(data));
}, [query]);
Ver solución
useEffect(() => {
  let ignore = false;                        // esta ejecucion tiene SU propia bandera
  fetchResults(query).then((data) => {
    if (!ignore) setResults(data);           // solo aplico si sigo vigente
  });
  return () => { ignore = true; };           // cleanup: al cambiar query, me invalido
}, [query]);

Cuando query cambia, React corre el cleanup del efecto anterior (ignore = true para la búsqueda vieja) y luego el nuevo efecto (con su propio ignore = false). Si la respuesta vieja llega tarde, su callback ve ignore = true y no llama a setResults: la descarta. La nueva, con su bandera en false, sí se aplica. La petición vieja igual viajó por la red; solo ignoramos su respuesta.

Ejercicio 2 — Predice el resultado. Con la "red" simulada del ejemplo, si las respuestas llegaran en el orden natural —primero a, luego ab— ¿qué daría la versión sin cleanup? ¿Y con cleanup? ¿Por qué la versión sin cleanup "funciona por casualidad" en este orden?

Ver solución
  • Sin cleanup, orden natural (a luego ab): llega asetResults(results-for-a); llega absetResults(results-for-ab). Final: results-for-ab. Correcto... por casualidad. Funcionó solo porque las respuestas llegaron en el mismo orden en que se pidieron.
  • Con cleanup, orden natural: el cleanup de a puso ignore = true, así que la respuesta de a se ignora aunque llegue primero; ab se aplica. Final: results-for-ab. Correcto siempre.

La moraleja: la versión sin cleanup a veces da el resultado correcto —cuando la red entrega en orden—, y por eso el bug es tan traicionero: en desarrollo, con red rápida y local, casi siempre "funciona". En producción, con red variable, falla de forma intermitente. El cleanup hace que el resultado sea correcto sin importar el orden de llegada.

Ejercicio 3 — Interpreta StrictMode. Un compañero dice: "mi efecto de fetch corre dos veces al cargar la página en desarrollo, hace dos peticiones. ¿Cómo lo arreglo?". Explícale qué está pasando y qué debe hacer (y qué no debe hacer).

Ver solución

Lo que pasa: en desarrollo, StrictMode monta el componente, corre el efecto, corre su cleanup y lo vuelve a montar (setup → cleanup → setup). Por eso el efecto corre dos veces y hace dos peticiones. Esto no ocurre en producción; es una prueba deliberada de React para verificar que el efecto limpia bien y es idempotente.

Qué debe hacer: asegurarse de que el efecto tenga la bandera ignore en el cleanup. Con ella, aunque el efecto corra dos veces, el cleanup del primer montaje descarta su respuesta (ignore = true), y solo se aplica la del segundo: no hay datos duplicados ni race. La segunda petición es inofensiva (o la evita una librería con dedup/caché).

Qué no debe hacer: quitar <StrictMode> para que "deje de correr dos veces". Eso oculta el síntoma en desarrollo pero deja el problema real (efecto sin cleanup) intacto para producción. StrictMode revela el bug; no lo causa.

Resumen y siguiente paso

En esta lección abriste las dos trampas del fetch crudo y las cerraste con la misma herramienta. La race condition: dos peticiones que resuelven fuera de orden, donde la respuesta vieja pisa a la nueva —lo mediste con ab y a llegando al revés—; la solución es el cleanup con una bandera ignore, que descarta la respuesta obsoleta (sin cleanup gana el obsoleto, results-for-a; con cleanup gana el vigente, results-for-ab, mismo orden de llegada). El doble efecto de StrictMode en desarrollo (setup → cleanup → setup): no es un bug, es React destapando los efectos sin cleanup —con cleanup queda 1 conexión, sin cleanup quedan 2—, y la respuesta es agregar el cleanup, nunca desactivar StrictMode. Y viste por qué existen React Query y SWR: para no escribir a mano la race, la caché, la dedup y lo demás —el problema lo viste crudo aquí; la solución con su API es la guía de estado y datos—.

Antes de avanzar deberías poder: explicar qué es una race condition y por qué la respuesta vieja puede ganar; escribir la bandera ignore en el cleanup; interpretar el doble efecto de StrictMode y responder correctamente; y justificar por qué en producción se usa una librería de datos.

La lección 7 da vuelta la moneda del módulo. Hasta aquí aprendiste a usar useEffect bien; ahora aprenderás cuándo no usarlo. Muchos efectos que la gente escribe no deberían existir: sincronizan estado con estado cuando el dato se podía derivar en el render (módulo 5). Lo mediremos: un efecto que guarda la lista filtrada en estado provoca renders de más; derivarla en el render da el mismo resultado con la mitad de renders. El mejor efecto suele ser el que no escribes.

Recursos

  • React, "Synchronizing with Effects" — react.dev/learn/synchronizing-with-effects. La sección "Fetching data" muestra la bandera ignore para la race condition, y "How to handle the Effect firing twice in development" explica StrictMode; las dos referencias directas de esta lección. En inglés.
  • React, "useEffect: Fetching data with Effects" (referencia) — react.dev/reference/react/useEffect#fetching-data-with-effects. El patrón con ignore, la nota sobre sus desventajas y la recomendación de usar un framework o una librería de datos. En inglés.
  • TkDodo, "React Query as a State Manager" — tkdodo.eu/blog/react-query-as-a-state-manager. Por qué el fetch con useEffect se queda corto y qué resuelve React Query (caché, dedup, revalidación); el puente hacia la guía de estado y datos. En inglés.
  • MDN, "AbortController" — developer.mozilla.org/en-US/docs/Web/API/AbortController. Cómo abortar de verdad una petición en la red (la optimización sobre la bandera ignore); mencionado en la doc de React. En inglés.