Módulo 4: Server State Is Different

Race conditions y copias stale

Descripción

La lección 4 midió dos males del fetching manual que son de cantidad (cuántas peticiones se hacen). Esta mide dos que son de cuándo: en qué momento llega y en qué momento se vuelve a pedir. El primero es la race condition: cuando disparas dos peticiones seguidas —por ejemplo, el usuario teclea "mou" y enseguida "mouse" en la búsqueda—, las respuestas pueden llegar fuera de orden, y el fetching manual, que pinta lo que llega al final, termina mostrando la respuesta vieja bajo el texto nuevo. El segundo es la falta de revalidación: useEffect(() => {...}, []) corre una sola vez, al montar, así que la copia que trajo queda stale para siempre cuando el backend cambia —no hay nada que dispare una segunda petición—. Los dos son bugs de temporización, y los dos obligan a escribir, a mano y en cada componente, una lógica delicada que es fácil olvidar.

La race condition de búsqueda es la que react-fundamentals M6 apenas señaló: te mostró que un useEffect que hace fetch puede necesitar un "ignore flag" o un cleanup para no aplicar respuestas viejas, y ahí lo dejó. Este módulo cierra ese cabo: te muestra el bug medido, la cura (un identificador creciente de petición para descartar las viejas), y —lo importante— por qué escribir esa cura en cada componente que hace fetch no escala. La falta de revalidación, por su parte, es la consecuencia directa de la propiedad "se pone stale" (lección 3): sin una política de cuándo volver a pedir, la copia envejece y nadie la refresca.

Conexión con el módulo. Las lecciones 4 y 5 juntas cubren cuatro de los cinco males del fetching manual: sin caché, sin dedup (L4), race y sin revalidación (L5). La lección 6 cubre el quinto (loading/error repetido), y la 7 muestra la capa que resuelve los cinco. Aquí verás por qué manejar el "cuándo" a mano —descartar respuestas viejas, decidir cuándo refetchar— es especialmente frágil: no falla en la demo, falla en producción con red real y usuarios rápidos. La cura integrada (una capa que descarta respuestas viejas por un seq y revalida según una política) es lo que React Query trae hecho (M5); aquí ves ambas mecánicas ejecutadas.

Una analogía: dos cartas que llegan en desorden y la foto que nadie refresca

Dos imágenes, una por cada mal.

La race condition: dos cartas en desorden. Le escribes a un amigo el lunes: "reunámonos el martes". El martes cambias de opinión y le escribes otra carta: "mejor el jueves". Mandaste dos cartas, la segunda cancela la primera. Pero el correo es caprichoso: la carta del jueves (la nueva) llega el miércoles, y la del martes (la vieja) llega el jueves —fuera de orden—. Si tu amigo hace caso a "la última que llegó", va a creer que quedaron el martes, porque esa carta llegó de último, aunque sea la instrucción más vieja. El bug no es del correo: es de la regla "hazle caso a la última que llegó" cuando las cartas pueden llegar en desorden. La regla correcta es "hazle caso a la más reciente que escribí, sin importar cuándo llegue" —y para eso hay que numerar las cartas—.

La revalidación: la foto que nadie refresca. Vuelve al saldo del banco. Tomas una foto del saldo a las 9:00 —"$1,000"— y la pegas en la pared. Esa foto no se actualiza sola: a las 11:00, cuando el saldo real es "$1,200", la foto en la pared sigue diciendo $1,000, para siempre, porque nadie sacó una foto nueva. useEffect(fetch, []) es exactamente eso: sacas la foto al montar y la pegas; como el efecto tiene [], nunca vuelve a correr, así que nadie saca una foto nueva. La cura es tener una política: "saco una foto nueva cuando vuelvo a mirar la pared (vuelvo a la pestaña), o cuando sé que hubo un movimiento (una mutación), o cada cierto tiempo". Sin política, la foto envejece y miente.

Ejemplo trabajado: la búsqueda que pinta lo viejo, y la copia que nunca revalida

Modelamos en Node los dos males y sus curas. Para la race, una red que entrega las respuestas en el orden que le indiquemos (determinista, sin promesas reales), para forzar el desorden. Primero, cómo se ve el problema en React —el useEffect de búsqueda que M6 dejó con la advertencia—:

// El usuario teclea; cada cambio dispara un fetch. Las respuestas pueden volver EN DESORDEN.
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  useEffect(() => {
    fetch('/search?q=' + query)
      .then((r) => r.json())
      .then(setResults); // <-- pinta lo que llegue, sea viejo o nuevo: BUG de race
  }, [query]);
  return <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>;
}
// react-fundamentals M6 avisó: aquí hace falta un "ignore flag" en el cleanup. Vamos a verlo.

Ejecutemos los dos males y sus curas:

// Por que useState+useEffect esta MAL a escala (parte 2): RACE y sin REVALIDACION.
// Ambos son bugs de "cuando" ocurre el fetch. Red simulada: entregamos respuestas
// en el orden que elijamos (determinista, sin promesas reales).

console.log('=== useState+useEffect: race conditions y sin revalidacion ===\n');

// ---------- 1) RACE: dos busquedas que resuelven FUERA DE ORDEN ----------
// El usuario teclea "mou" y luego "mouse". Se disparan dos fetches.
// La red devuelve "mouse" (la nueva) primero y "mou" (la vieja) DESPUES.
const resultsFor = {
  mou:   ['Mouse', 'Mousepad', 'Mouse Wireless'], // 3 resultados
  mouse: ['Mouse', 'Mouse Wireless'],             // 2 resultados
};

console.log('1) RACE — dos busquedas resuelven fuera de orden');
console.log('   el usuario teclea "mou" y luego "mouse" (el input final dice "mouse")');
console.log('   la red responde "mouse" primero y "mou" despues\n');

// NAIVE: cada respuesta que llega PISA el estado, sin importar si ya es vieja.
let displayedNaive = null;
function onResponseNaive(query) { displayedNaive = resultsFor[query]; }
onResponseNaive('mouse'); // llega la respuesta NUEVA
onResponseNaive('mou');   // llega la respuesta VIEJA despues y la pisa
console.log('   NAIVE (pisa siempre):   input="mouse" pero muestra ' + JSON.stringify(displayedNaive));
console.log('                           -> BUG: resultados de "mou" bajo el texto "mouse"');

// GUARDED: cada peticion lleva un id creciente; solo la MAS RECIENTE puede pintar.
let latestId = 0, displayedGuarded = null;
function dispatch() { latestId++; return latestId; } // id de esta peticion
function onResponseGuarded(query, reqId) {
  if (reqId === latestId) displayedGuarded = resultsFor[query]; // ignora respuestas viejas
}
const idMou = dispatch();   // peticion 1: "mou"
const idMouse = dispatch(); // peticion 2: "mouse" (la ultima)
onResponseGuarded('mouse', idMouse); // reqId 2 == latestId 2 -> pinta
onResponseGuarded('mou', idMou);     // reqId 1 != latestId 2 -> se ignora
console.log('   GUARDED (id creciente): input="mouse" y muestra ' + JSON.stringify(displayedGuarded));
console.log('                           -> correcto: la respuesta vieja se descarta');
console.log('   (este guard hay que escribirlo A MANO en cada componente que hace fetch)\n');

// ---------- 2) SIN REVALIDACION: useEffect(() => ..., []) corre UNA vez ----------
const backend = {
  product: { name: 'Wireless Mouse', priceCents: 2599 },
  fetch() { return { ...this.product }; },
};
const money = (c) => '$' + (c / 100).toFixed(2);

console.log('2) SIN REVALIDACION — useEffect(fetch, []) corre solo al montar');
let mounted = false;
let copy = null;
function componentMountNaive() { // useEffect(() => fetch(), [])
  if (!mounted) { copy = backend.fetch(); mounted = true; } // corre UNA sola vez
}
componentMountNaive();
console.log('   al montar:  ' + money(copy.priceCents));
backend.product.priceCents = 1999; // el backend cambia despues
componentMountNaive();             // el efecto NO vuelve a correr (deps [])
console.log('   backend cambio a ' + money(backend.fetch().priceCents) + ', pero el efecto no re-corre');
console.log('   la copia sigue: ' + money(copy.priceCents) + '   <- STALE para siempre\n');

// CON una politica de revalidacion: la capa vuelve a pedir en ciertos momentos.
backend.product.priceCents = 2599; // reiniciamos el backend para ver la revalidacion desde cero
const cache = {
  data: null, stale: true,
  read() { return this.data; },
  markStale() { this.stale = true; },              // "lo que tengo ya no es de fiar"
  revalidate() { if (this.stale) { this.data = backend.fetch(); this.stale = false; } },
};
cache.revalidate();
console.log('   CON revalidacion (la capa vuelve a pedir):');
console.log('   al montar:  ' + money(cache.read().priceCents));
backend.product.priceCents = 1999; // el backend vuelve a cambiar
cache.markStale();   // p.ej. el usuario volvio a la pestana, o hubo una mutacion
cache.revalidate();  // la capa refetcha
console.log('   backend cambio a ' + money(backend.fetch().priceCents) + '; la capa marca stale y revalida');
console.log('   tras revalidar: ' + money(cache.read().priceCents) + '   <- FRESH');

console.log('\nLos dos son bugs de "cuando": el race pinta una respuesta vieja; el [] nunca refetcha.');
console.log('Manejar el "cuando" a mano en cada componente no escala. Una capa lo centraliza.');

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

=== useState+useEffect: race conditions y sin revalidacion ===

1) RACE — dos busquedas resuelven fuera de orden
   el usuario teclea "mou" y luego "mouse" (el input final dice "mouse")
   la red responde "mouse" primero y "mou" despues

   NAIVE (pisa siempre):   input="mouse" pero muestra ["Mouse","Mousepad","Mouse Wireless"]
                           -> BUG: resultados de "mou" bajo el texto "mouse"
   GUARDED (id creciente): input="mouse" y muestra ["Mouse","Mouse Wireless"]
                           -> correcto: la respuesta vieja se descarta
   (este guard hay que escribirlo A MANO en cada componente que hace fetch)

2) SIN REVALIDACION — useEffect(fetch, []) corre solo al montar
   al montar:  $25.99
   backend cambio a $19.99, pero el efecto no re-corre
   la copia sigue: $25.99   <- STALE para siempre

   CON revalidacion (la capa vuelve a pedir):
   al montar:  $25.99
   backend cambio a $19.99; la capa marca stale y revalida
   tras revalidar: $19.99   <- FRESH

Los dos son bugs de "cuando": el race pinta una respuesta vieja; el [] nunca refetcha.
Manejar el "cuando" a mano en cada componente no escala. Una capa lo centraliza.

Desmenuza los dos males.

1) La race, medida. El usuario tecleó "mou" y luego "mouse" —el input final dice "mouse"—. La red devolvió "mouse" primero y "mou" después. La versión naive pinta lo que llega, así que la última en llegar ("mou") ganó: la UI muestra ["Mouse","Mousepad","Mouse Wireless"] —los resultados de "mou" debajo del texto "mouse"—. Es la carta vieja que llegó de último y tu amigo le hizo caso. La versión guarded numera cada petición con un id creciente y solo pinta si la respuesta corresponde a la petición más reciente (reqId === latestId): cuando llegó la respuesta vieja de "mou" (id 1) pero la última petición era "mouse" (id 2), la descartó. La UI muestra ["Mouse","Mouse Wireless"] —correcto—. La cura funciona, pero fíjate en la nota: ese guard hay que escribirlo a mano en cada componente que hace fetch. Un useEffect de búsqueda sin ese guard es un bug esperando a un usuario que teclee rápido con red lenta.

2) La revalidación, medida. La copia manual se hizo al montar ($25.99); el backend bajó el precio a $19.99; el efecto, con [], no volvió a correr, así que la copia se quedó en $25.99stale para siempre—. Es la foto pegada en la pared que nadie refresca. La versión con revalidación hace lo que el [] no: cuando sabe que la copia puede haber envejecido (el usuario volvió a la pestaña, hubo una mutación, pasó cierto tiempo), la marca stale y revalida —vuelve a pedir—, obteniendo $19.99 fresh. La diferencia no es "más código porque sí": es que el estado del servidor exige una política de cuándo refetchar, y el useEffect(fetch, []) no tiene ninguna.

Los dos males comparten una lección: el fetching manual te obliga a manejar el "cuándo" —cuándo descartar una respuesta, cuándo volver a pedir— a mano y en cada componente, y esos detalles son fáciles de olvidar y difíciles de reproducir. No fallan en la demo (un solo componente, red instantánea, datos que no cambian); fallan en producción (muchos componentes, red variable, verdad que se mueve). Una capa centraliza ese "cuándo" una vez, bien, para todos.

Por qué el "ignore flag" a mano no escala

La cura de la race que react-fundamentals M6 mencionó es el "ignore flag" en el cleanup del useEffect:

useEffect(() => {
  let ignore = false;                          // esta corrida "sigue vigente"
  fetch('/search?q=' + query)
    .then((r) => r.json())
    .then((data) => { if (!ignore) setResults(data); }); // solo pinta si sigue vigente
  return () => { ignore = true; };             // al cambiar query, invalida la corrida anterior
}, [query]);

Es correcto, y es la versión React del id creciente del ejemplo: cada corrida del efecto tiene su bandera, y el cleanup la baja cuando query cambia, así que las respuestas de corridas viejas se descartan. Pero mira lo que implica: cada componente que hace fetch necesita este patrón —la bandera, el chequeo if (!ignore), el cleanup—, escrito a mano, sin olvidarse de ninguna parte. Multiplícalo por los diez, veinte, cincuenta lugares de una app que piden datos, y súmale que también necesitan caché, dedup, loading, error y revalidación (los otros males), y verás por qué el ecosistema no escribe esto a mano: lo delega a una capa. Una librería de estado del servidor hace el descarte de respuestas viejas por ti, en un solo lugar probado, para todas las queries. Tú declaras qué dato quieres; ella se encarga del "cuándo".

Errores comunes

Ignorar la race porque "en la demo nunca pasa". Qué pasa: el useEffect de búsqueda pinta lo que llega, sin guard, y funciona en desarrollo. Por qué pasa: con red instantánea y un solo usuario tecleando despacio, las respuestas casi siempre llegan en orden. Cómo detectarlo: en producción, reportes de "la búsqueda muestra resultados de lo que escribí antes", intermitentes e imposibles de reproducir a voluntad. Cómo corregirlo: toda búsqueda o fetch dependiente de un valor que cambia necesita descartar respuestas viejas —el ignore flag o, mejor, una capa que lo haga por ti—. No es un caso raro; es lo que pasa en cuanto la red es real.

Creer que useEffect(fetch, []) "se actualiza solo". Qué pasa: se asume que, como el componente "está montado", los datos se mantienen frescos. Por qué pasa: se confunde "montado" con "vivo y sincronizado". Cómo detectarlo: la app muestra datos viejos tras cambios del backend, y solo se arreglan al recargar la página. Cómo corregirlo: [] significa "corre una vez y nunca más". Sin una política de revalidación (al enfocar la ventana, tras una mutación, por tiempo), la copia queda stale. La revalidación es una decisión de diseño, no algo automático del useEffect.

Meter dependencias en el useEffect para "forzar" refetch y crear loops. Qué pasa: al querer que el efecto refetche, se agregan objetos o funciones a las dependencias, y como se recrean en cada render, el efecto corre en bucle. Por qué pasa: se intenta resolver la revalidación empujando el array de dependencias. Cómo detectarlo: peticiones que se disparan sin parar, la pestaña de red inundada. Cómo corregirlo: la revalidación no se logra manipulando dependencias frágiles; se logra con una política explícita (invalidar la query, refetch al enfocar, staleTime). Ese es justo el trabajo de una capa de caché —lo verás en L7 y en React Query—.

Ejercicios

Ejercicio 1 — Predice la race. El usuario teclea "ke", luego "key", luego "keyb" en la SearchBar. La red devuelve las respuestas en este orden: "key" (2°), "keyb" (3°), "ke" (1°). Con la versión naive (pinta lo que llega), ¿qué resultados muestra la UI al final, y qué texto hay en el input? ¿Y con la versión guarded?

Ver solución

El input final dice "keyb" (fue lo último que tecleó el usuario). Las respuestas llegan en el orden: "key", "keyb", "ke".

  • Naive (pinta lo que llega): la última en llegar es "ke", así que la UI termina mostrando los resultados de "ke" bajo el texto "keyb". Bug: muestra resultados de dos letras cuando el usuario escribió cuatro. Es exactamente la carta más vieja llegando de último.
  • Guarded (id creciente): las peticiones se numeran 1="ke", 2="key", 3="keyb"; latestId termina en 3. Cuando llega "key" (id 2 ≠ 3) se descarta; cuando llega "keyb" (id 3 == 3) se pinta; cuando llega "ke" (id 1 ≠ 3) se descarta. La UI muestra los resultados de "keyb" —correcto—, sin importar el desorden de llegada.

La clave: la naive se guía por orden de llegada; la guarded, por cuál es la petición más reciente. Solo la segunda es correcta cuando la red desordena.

Ejercicio 2 — Diseña la política de revalidación. Para el catálogo de Mercado, useEffect(fetch, []) deja la copia stale. Propón al menos tres momentos concretos en que tendría sentido revalidar los productos, y di qué cambio del backend cubre cada uno.

Ver solución

Tres momentos razonables (los tres que React Query trae como opciones):

  1. Al volver a la pestaña (el usuario regresa al navegador tras estar en otra app). Cubre cambios que ocurrieron mientras no miraba: precios que cambiaron, productos agotados. Es "saco una foto nueva al volver a mirar la pared".
  2. Después de una mutación (tras comprar, revalidar el stock; tras dejar una reseña, revalidar las reseñas). Cubre el cambio que tú mismo provocaste, para que la UI lo refleje. (Es el ciclo write → invalidate → refetch del módulo 6.)
  3. Cada cierto tiempo (revalidación periódica), útil para datos que cambian rápido y que el usuario mira mucho rato —un stock en una venta flash—. Cubre cambios continuos de otros usuarios.

La idea de fondo: la copia siempre puede envejecer, así que "cuándo revalidar" es una política de diseño, no un efecto automático. useEffect(fetch, []) no tiene ninguna; una capa las trae listas (refetchOnWindowFocus, invalidateQueries, staleTime).

Ejercicio 3 — Por qué el guard a mano no escala. Un compañero dice: "la race se arregla con el ignore flag; lo pongo en los tres componentes que hacen búsqueda y listo". Explica por qué esa solución, aunque correcta, no escala, y qué la reemplaza.

Ver solución

El ignore flag es correcto, pero es una responsabilidad que recae en cada componente que hace fetch dependiente de un valor: hay que escribir la bandera, el chequeo if (!ignore) y el cleanup, sin olvidar ninguna parte, en los tres componentes de búsqueda… y en el siguiente que alguien agregue, y en el de autocompletar, y en el de filtros. Es una promesa de "me acordaré de ponerlo siempre", y tarde o temprano un componente nuevo se queda sin él y reaparece la race. Además, el ignore flag solo cura la race: ese componente sigue sin caché, sin dedup, sin revalidación y con su loading/error a mano.

Lo que lo reemplaza es una capa de estado del servidor que descarta las respuestas viejas por ti, en un solo lugar probado, para todas las queries —además de darte caché, dedup, revalidación y estados de carga—. Tú declaras useQuery(['search', query], ...) y la capa se encarga de que solo la respuesta de la query vigente pinte. El "cuándo" deja de ser tu problema por componente y pasa a ser responsabilidad de la capa, una vez. Esa capa es React Query (M5).

Resumen y siguiente paso

En esta lección mediste los dos males de cuándo del fetching manual. La race condition: dos búsquedas ("mou", "mouse") cuyas respuestas llegan fuera de orden hacen que la versión naive pinte la vieja ("mou") bajo el texto nuevo ("mouse"); la cura es un id creciente que descarta las respuestas de peticiones ya superadas —el cabo que react-fundamentals M6 dejó suelto—. La falta de revalidación: useEffect(fetch, []) corre una vez, así que la copia queda stale para siempre cuando el backend cambia; la cura es una política de cuándo volver a pedir. Los anclaste con dos cartas en desorden y la foto que nadie refresca, y viste por qué manejar el "cuándo" a mano en cada componente —aunque correcto— no escala.

Antes de avanzar deberías poder: explicar por qué las respuestas fuera de orden pintan lo viejo y cómo lo cura un id creciente; reconocer que [] significa "una vez y nunca más"; diseñar una política de revalidación; y justificar por qué el ignore flag por componente no escala.

La lección 6 mide el quinto y último mal del fetching manual: el estado loading/error repetido en cada componente. Como pedir es asíncrono y falible (lección 3), cada componente tiene que re-implementar la máquina loading → success | error —tres useState, un useEffect—, y verás algo peor: como son copias independientes, tres componentes pueden estar en estados distintos para el mismo dato a la vez (uno cargando, uno en error, uno con datos), produciendo una UI incoherente. El último mal antes de la síntesis.

Recursos