Módulo 5: Derived State And Lists

Por qué el derivado guardado se desincroniza

Descripción

Este es el corazón del módulo, y la lección que justifica todas las demás. Hasta ahora te pedimos que confiaras en "derivar, no guardar" como una regla. Aquí pagamos esa confianza con evidencia: vas a ver medido qué pasa exactamente cuando guardas en estado un valor que se podía derivar. La respuesta tiene nombre —desincronización— y una vez que la ves ejecutada, no vas a volver a guardar una lista filtrada mientras vivas.

El mecanismo es este. Cuando guardas filteredProducts en su propio useState, creas dos fuentes de verdad para el mismo hecho ("qué productos coinciden con la búsqueda"): la fuente real (products + query) y la copia guardada. Esas dos cosas solo coinciden mientras nadie las mueva. Pero los datos cambian —baja un precio, llega un producto, se agota otro—, y cuando products cambia, la copia guardada no se entera sola: sigue mostrando la foto vieja hasta que alguien se acuerde de recalcularla. En ese hueco, la interfaz miente: muestra una lista que ya no corresponde a los datos. Derivar elimina el hueco por completo, porque no hay copia: la lista se produce fresca de la fuente en cada render, siempre correcta.

Conexión con el módulo. Las lecciones 1 a 3 te dieron la idea, la decisión y la mecánica de derivar. Esta te da el porqué, cuantificado: el costo concreto de equivocarse. Es el argumento que convierte "derivar, no guardar" de consejo en ley. Lo que sigue —el ProductList de Mercado (L5), la key (L6), la decisión (L7)— asume que ya interiorizaste esta lección: nunca guardas lo que puedes derivar, porque guardar se desincroniza.

Una analogía: la agenda de contactos duplicada

Imagina que tienes los teléfonos de tus amigos guardados en tu celular. Un día decides, "por comodidad", copiarlos también a una libreta de papel. Ahora tienes el mismo dato en dos lugares: el celular y la libreta. Mientras nadie cambie de número, las dos coinciden y todo parece bien —de hecho, la libreta parece útil—.

Pero un amigo cambia de número. Lo actualizas en el celular (la fuente, la que usas a diario). ¿Y la libreta? La libreta sigue con el número viejo, porque nadie la actualizó —ni siquiera te acordaste de que existía—. Semanas después, sin el celular a mano, tomas la libreta, marcas el número que dice… y llamas a un desconocido. La libreta no "falló": hizo exactamente lo que una copia hace —quedarse congelada en el momento en que se copió—. El problema fue tener dos fuentes para el mismo dato: en cuanto una cambió y la otra no, la copia empezó a mentir.

La única forma de que la libreta nunca mienta es no tenerla: guardar el número en un solo lugar (el celular) y consultarlo ahí siempre. En React, filteredProducts guardado es la libreta de papel; products + query es el celular. La lista filtrada derivada es abrir el celular y ver el número actual en el momento de llamar: nunca viejo, porque no hay copia que envejecer. Guardar el derivado es fabricarte una libreta que, tarde o temprano, te hace llamar al número equivocado.

Las dos estrategias, frente al mismo cambio

Vamos a poner las dos formas de manejar la lista filtrada lado a lado, y a someterlas al mismo cambio en los datos, para medir cuál miente.

Estrategia A — guardar. Calculas filteredProducts una vez y lo metes en estado (imagina un setFilteredProducts(...)). A partir de ahí, la UI lee esa copia guardada.

Estrategia B — derivar. No guardas nada. En cada render calculas products.filter(query) sobre los products actuales.

Ahora el cambio: el usuario buscó "e" y ve su lista. Después, el inventario cambia —cosa que en una app real pasa todo el tiempo—: baja el precio del teclado (de $89.00 a $49.00, una rebaja) y llega un producto nuevo, "Extra Cable", que también contiene "e" y debería aparecer en la búsqueda. El query no cambió (sigue siendo "e"); lo que cambió fueron los products.

La pregunta que la ejecución responde: después de ese cambio, ¿qué muestra cada estrategia?

Ejemplo trabajado: la desincronización, medida

Modelamos las dos estrategias con datos fijos. storedFiltered es la copia guardada (Estrategia A): se calcula una vez, con los productos iniciales, y nadie la vuelve a tocar. La Estrategia B deriva llamando a filterByQuery(products, query) sobre los products actuales. Cambiamos products (rebaja + producto nuevo) y comparamos.

'use strict';
// Por qué GUARDAR la lista derivada se DESINCRONIZA.
// Dos estrategias frente al MISMO cambio en los datos:
//   A) GUARDAR filteredProducts en estado (se calcula una vez y se guarda)
//   B) DERIVAR en el render (se recalcula cada vez desde products + query)

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

function filterByQuery(products, query) {
  const q = query.trim().toLowerCase();
  return products.filter((p) => p.name.toLowerCase().includes(q));
}

function summarize(list) {
  const total = list.reduce((s, p) => s + p.priceCents, 0);
  const names = list.map((p) => p.name + ' ' + formatPrice(p.priceCents)).join(', ');
  return list.length + ' items | total ' + formatPrice(total) + ' | ' + names;
}

// --- Momento 1: el usuario busca 'e'. Hay 5 productos. ---
let products = [
  { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599 },
  { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
  { id: 'p3', name: 'USB-C Hub',           priceCents: 3499 },
  { id: 'p4', name: 'Laptop Stand',        priceCents: 4500 },
  { id: 'p5', name: 'Desk Lamp',           priceCents: 1999 },
];
const query = 'e';

// ESTRATEGIA A: calculamos una vez y GUARDAMOS el resultado en una variable
// (imagina un setFilteredProducts(...) que lo mete en estado).
let storedFiltered = filterByQuery(products, query);

console.log('=== Momento 1: query="e", con los 5 productos iniciales ===');
console.log('A) guardado :', summarize(storedFiltered));
console.log('B) derivado :', summarize(filterByQuery(products, query)));
console.log('(iguales: al principio nadie nota la diferencia)\n');

// --- Momento 2: el INVENTARIO cambia. Baja el precio del teclado (8900 -> 4900)
//     y llega un producto nuevo que también matchea 'e' ("Extra Cable"). ---
products = [
  { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599 },
  { id: 'p2', name: 'Mechanical Keyboard', priceCents: 4900 }, // <- rebaja
  { id: 'p3', name: 'USB-C Hub',           priceCents: 3499 },
  { id: 'p4', name: 'Laptop Stand',        priceCents: 4500 },
  { id: 'p5', name: 'Desk Lamp',           priceCents: 1999 },
  { id: 'p6', name: 'Extra Cable',         priceCents: 1299 }, // <- nuevo, matchea 'e'
];
// NADIE volvió a llamar a setFilteredProducts: 'storedFiltered' sigue igual.
// El derivado se recalcula solo al usar products actual.

console.log('=== Momento 2: bajó el teclado y llegó "Extra Cable" (mismo query) ===');
console.log('A) guardado :', summarize(storedFiltered));
console.log('B) derivado :', summarize(filterByQuery(products, query)));

const storedTotal = storedFiltered.reduce((s, p) => s + p.priceCents, 0);
const derivedList = filterByQuery(products, query);
const derivedTotal = derivedList.reduce((s, p) => s + p.priceCents, 0);
console.log('\n=== la desincronización, medida ===');
console.log('items:  guardado =', storedFiltered.length, ' derivado =', derivedList.length,
  ' (el guardado perdió a "Extra Cable")');
console.log('total:  guardado =', formatPrice(storedTotal), ' derivado =', formatPrice(derivedTotal),
  ' (el guardado cobra el teclado viejo)');
console.log('¿coinciden?', storedTotal === derivedTotal);

Y así se ve, en React, el error que estamos modelando —guardar la lista filtrada en su propio estado— comparado con la forma correcta:

// ❌ MAL: dos fuentes de verdad. filtered se desincroniza de products/query.
function ProductList({ products }) {
  const [query, setQuery] = useState('');
  const [filtered, setFiltered] = useState(products); // copia guardada

  function handleSearch(text) {
    setQuery(text);
    setFiltered(products.filter((p) => p.name.includes(text))); // hay que recordar esto
  }
  // si products cambia por fuera, 'filtered' queda stale: nadie lo recalcula
  // ...
}

// ✅ BIEN: una sola fuente. La lista se deriva; no puede quedar stale.
function ProductList({ products }) {
  const [query, setQuery] = useState('');
  const filtered = products.filter((p) => p.name.includes(query)); // derivado
  // ...
}

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

=== Momento 1: query="e", con los 5 productos iniciales ===
A) guardado : 3 items | total $134.98 | Wireless Mouse $25.99, Mechanical Keyboard $89.00, Desk Lamp $19.99
B) derivado : 3 items | total $134.98 | Wireless Mouse $25.99, Mechanical Keyboard $89.00, Desk Lamp $19.99
(iguales: al principio nadie nota la diferencia)

=== Momento 2: bajó el teclado y llegó "Extra Cable" (mismo query) ===
A) guardado : 3 items | total $134.98 | Wireless Mouse $25.99, Mechanical Keyboard $89.00, Desk Lamp $19.99
B) derivado : 4 items | total $107.97 | Wireless Mouse $25.99, Mechanical Keyboard $49.00, Desk Lamp $19.99, Extra Cable $12.99

=== la desincronización, medida ===
items:  guardado = 3  derivado = 4  (el guardado perdió a "Extra Cable")
total:  guardado = $134.98  derivado = $107.97  (el guardado cobra el teclado viejo)
¿coinciden? false

Lee la salida en dos tiempos. En el Momento 1, las dos estrategias dan exactamente lo mismo: 3 items, total $134.98, los mismos productos. Esto es crucial de entender, porque es la trampa: al principio, guardar parece funcionar perfecto. Nadie nota nada. Por eso el bug es tan común y tan traicionero —el código guardado se ve correcto en la demo, en el primer render, en las pruebas rápidas—.

En el Momento 2, los datos cambiaron (misma búsqueda "e"), y las dos estrategias discrepan:

  • El guardado sigue diciendo 3 items con el teclado a $89.00 y sin "Extra Cable". Es la foto vieja, congelada en el instante en que se calculó. El precio rebajado no se reflejó; el producto nuevo no apareció. La copia envejeció, exactamente como la libreta de teléfonos.
  • El derivado dice 4 items: recogió a "Extra Cable" (que contiene "e") y muestra el teclado con su precio nuevo, $49.00. Total $107.97. Está correcto, porque se recalculó sobre los products actuales.

Y la parte medida lo clava con números: los items no coinciden (3 vs 4 —el guardado perdió a "Extra Cable"—), los totales no coinciden ($134.98 vs $107.97 —el guardado cobra el teclado al precio viejo—), y ¿coinciden? false. Esa última línea es el bug hecho dato: dos fuentes de verdad para "los productos que coinciden con la búsqueda", y ya divergieron. Un usuario mirando la versión guardada vería una lista incompleta con un precio equivocado, sin ningún error visible, sin nada que "falle" —solo información que miente—.

Profundización: por qué derivar hace el bug imposible

Fíjate en por qué el derivado no puede tener este problema, porque la razón es estructural, no cuestión de "acordarse mejor".

Con la estrategia guardada, la corrección depende de una promesa humana: "cada vez que cambie products o query, me acordaré de recalcular filteredProducts". Esa promesa se rompe sola con el tiempo. Hay demasiados caminos por los que products puede cambiar —una recarga de datos, otro componente, una mutación del servidor— y cada uno tendría que acordarse de re-sincronizar la copia. Basta con que uno se olvide para que la copia mienta. La corrección es frágil porque descansa en no olvidar nunca.

Con la estrategia derivada, no hay ninguna promesa que cumplir, porque no hay copia. La lista filtrada no existe entre renders; se produce fresca cada vez que el componente se ejecuta, a partir de los products y query de ese render. React re-renderiza cuando el estado o las props cambian, y en ese re-render la derivación corre de nuevo con los datos nuevos. No hay un instante en que "la copia" esté desactualizada, porque no hay copia que desactualizar. La corrección es automática porque es una consecuencia de cómo funciona el render, no de la disciplina del programador.

Esa es la diferencia entre "correcto si no me olvido" y "correcto por construcción". La única fuente de verdad no es más ordenada: es incapaz de desincronizarse, porque no hay dos cosas que puedan discrepar. Ese es el regalo de derivar, y por qué vale la pena convertirlo en reflejo.

Profundización: las tres formas en que un derivado guardado se desincroniza

El bug que mediste —la copia queda stale cuando cambia la fuente— tiene varias caras. Reconocerlas te ayuda a olerlas antes de que muerdan. Guardar un derivado se desincroniza, al menos, de estas tres maneras:

  • Stale por cambio externo de la fuente. Es el que ejecutaste: products cambia por un camino que no recalcula la copia (una recarga de datos, otro componente, una mutación del servidor), y la copia guardada se queda con la foto vieja. Cuanto más grande la app, más caminos hay por los que la fuente puede cambiar, y más fácil que uno olvide re-sincronizar.

  • Stale por actualización parcial. Guardas cart y cartTotal, y un handler actualiza cart pero se olvida (o falla a medias) de actualizar cartTotal. Ahora el total no corresponde a los items. El bug no está en "cambió la fuente sin avisar", sino en "actualicé una de las dos copias y no la otra". Dos setters que hay que llamar juntos son dos setters que algún día se llamarán por separado.

  • Drift por doble fuente editable. Copias una prop en estado (useState(props.items)) y luego el usuario edita la copia local mientras el padre también cambia la prop. Ahora la copia y la prop divergieron por los dos lados, y no hay una "correcta": tienes dos verdades editándose en paralelo. Este es el más difícil de desenredar, porque ninguna de las dos es claramente la fuente.

Las tres tienen la misma raíz —dos lugares que guardan el mismo hecho— y la misma cura —dejar uno solo y derivar el resto—. Cuando veas dos piezas de estado donde una es función de la otra, o una copia de una prop, estás ante una de estas tres esperando a pasar. Derivar las elimina las tres de un golpe, porque quita el segundo lugar.

Errores comunes

Sincronizar la copia "a mano" en cada handler. Qué pasa: para mantener filteredProducts al día, se llama a setFiltered(...) en cada handler que toca query o products. Por qué pasa: parece que "si soy disciplinado, la copia se mantiene bien". Cómo detectarlo: la lógica de filtrado se repite en tres o cuatro lugares, y aparece un camino (una recarga de datos, una mutación) que cambia products sin pasar por ningún handler —y ahí la copia queda stale—. Cómo corregirlo: no sincronices; elimina la copia. Deriva filtered en el render. El trabajo de "mantenerla al día" desaparece porque no hay nada que mantener.

Creer que "al principio se ve bien" significa "está bien". Qué pasa: se prueba la versión guardada, funciona en la demo, y se da por correcta. Por qué pasa: en el primer render y con datos estáticos, guardar y derivar son indistinguibles —lo viste en el Momento 1—. Cómo detectarlo: el bug aparece solo cuando los datos cambian después del primer render, que es difícil de ver en una prueba rápida y fácil de ver en producción. Cómo corregirlo: no confíes en "se ve bien ahora"; la pregunta es "¿qué pasa cuando la fuente cambie?". Si el valor es derivado, la respuesta segura es siempre derivarlo.

Usar un useEffect para "recalcular" la copia cuando cambian las entradas. Qué pasa: useEffect(() => setFiltered(products.filter(...)), [products, query]). Por qué pasa: se piensa que un effect "escuchando" las entradas mantiene la copia sincronizada automáticamente. Cómo detectarlo: la UI parpadea o va un render por detrás, y tienes un estado que solo existe para copiar otro. Cómo corregirlo: derivar en el render hace ese effect innecesario y llega antes (lo mediremos en la lección 7). La documentación de React lo dice explícito: si puedes calcular algo durante el render, no necesitas un effect ni estado para ello. Este es el anti-patrón que la lección 7 desmonta ejecutándolo.

Ejercicios

Ejercicio 1 — Predice la desincronización. Un componente guarda sortedProducts en estado, calculado una vez de products ordenado por precio. Luego, sin recalcular sortedProducts, llega un producto nuevo más barato que todos a products. Describe qué muestra la lista guardada frente a la derivada, y cuál es el bug que vería el usuario.

Ver solución

La lista guardada (sortedProducts) muestra los productos originales ordenados, sin el producto nuevo: la copia se calculó antes de que llegara, y nadie la recalculó, así que no lo incluye. El usuario ve una lista incompleta —falta un producto que sí existe en el catálogo—, y encima el más barato de todos, que "debería" encabezar la lista ordenada, ni aparece.

La lista derivada ([...products].sort(...) en el render) incluye el producto nuevo en su posición correcta (al principio, por ser el más barato), porque se recalcula sobre los products actuales en cada render. El bug del usuario con la versión guardada: un producto que existe no se muestra, silenciosamente, hasta que algo fuerce un recálculo. Con derivar, imposible: la lista siempre refleja products.

Ejercicio 2 — Arréglalo. Este componente guarda el total en estado y lo sincroniza a mano. Reescríbelo para que el total sea derivado, y explica qué bug elimina.

function Cart({ items }) {
  const [total, setTotal] = useState(0);

  function addItem(item) {
    items.push(item);
    setTotal(total + item.priceCents);
  }

  return <p>Total: {formatPrice(total)}</p>;
}
Ver solución
function Cart({ items }) {
  const total = items.reduce((sum, item) => sum + item.priceCents, 0); // derivado

  return <p>Total: {formatPrice(total)}</p>;
}

(Y addItem debería actualizar items de forma inmutable en el estado que sea su dueño —módulo 3—, no con push; pero el punto de esta lección es el total.)

Qué bug elimina: el original tiene dos fuentes de verdad para el precio del carrito: items y total. Se desincronizan en varios escenarios —si items cambia por fuera de addItem, si addItem falla a medias, si el precio de un item cambia—, dejando total mostrando una cifra que no corresponde a los items reales. Derivando total de items en el render, hay una fuente (items), el total es siempre su suma exacta, y es imposible que muestre una cifra que no cuadre con los items presentes.

Ejercicio 3 — Explica el Momento 1. En la salida del ejemplo trabajado, en el Momento 1 las dos estrategias dan idéntico resultado. Explica por qué eso, lejos de ser tranquilizador, es precisamente lo que hace peligroso el error de guardar.

Ver solución

En el Momento 1, storedFiltered se acaba de calcular de los products actuales, así que la copia y la fuente todavía coinciden —no ha pasado nada que las separe—. Guardar y derivar son indistinguibles en ese instante.

Eso es peligroso porque es exactamente cuando probamos el código: recién escrito, primer render, datos estáticos. La versión guardada pasa la prueba con honores, se ve correcta, y se da por buena. El bug no existe todavía; nace después, cuando los datos cambian sin que la copia se recalcule (el Momento 2). Es un bug que la demo no revela y que producción sí, porque en producción los datos cambian con el tiempo. La lección: que una estrategia "se vea bien al principio" no dice nada sobre su corrección; hay que preguntar qué pasa cuando la fuente cambia, y ahí guardar falla y derivar no.

Resumen y siguiente paso

En esta lección viste, medido, por qué "derivar, no guardar" es ley y no preferencia. Guardar filteredProducts en estado crea dos fuentes de verdad —la fuente (products, query) y la copia—, y en cuanto los datos cambian sin que alguien recalcule la copia, la copia miente: en el Momento 2, la versión guardada seguía con 3 items, el teclado a $89.00 y sin "Extra Cable", mientras la derivada mostraba correctamente 4 items con el teclado a $49.00. La diferencia no es de disciplina: derivar hace el bug imposible por construcción, porque no hay copia que envejecer —la lista se produce fresca de la fuente en cada render—. Como la libreta de teléfonos: la única forma de que nunca mienta es no tenerla.

Antes de avanzar deberías poder: explicar qué es la desincronización y por qué guardar el derivado la causa; describir por qué "se ve bien al principio" es la trampa; y argumentar por qué derivar es correcto por construcción, no por no olvidar.

La lección 5 toma este hábito ya justificado y lo aplica al caso completo de Mercado: el ProductList visible como resultado de filtrar por búsqueda, filtrar por categoría y ordenar por precio —tres transformaciones encadenadas, todas derivadas—, incluido el estado vacío ("No products match your search") como una cosa derivada más. Vas a ver getVisibleProducts(products, query, category, sort) ejecutado con muchas combinaciones, y el JSX real del ProductList que lo usa.

Recursos