Módulo 5: Derived State And Lists

Presentación del módulo: derivar, no guardar

Por qué este módulo existe aquí

En los módulos 3 y 4 aprendiste a guardar cosas en estado y a cambiarlas cuando el usuario actúa. El estado (useState) es la memoria del componente; los eventos (onChange, onClick) son el eslabón que conecta la acción del usuario con el setter. Cuando el usuario teclea en la SearchBar, onChange llama a setQuery, el estado cambia, y React re-renderiza. Ya dominas el "cómo guardo y actualizo un dato".

Este módulo enseña la otra mitad del criterio, la que casi nadie te explica cuando empiezas: qué NO guardar. Porque no todo lo que la interfaz muestra es estado. La mayoría de lo que ves en pantalla —la lista filtrada, el número de resultados, el total del carrito, el aviso de "sin resultados"— no es un dato que guardes: es un dato que calculas a partir de otros que sí guardas. A eso se le llama estado derivado, y la tesis del módulo cabe en tres palabras: derivar, no guardar.

Mira la tentación de frente, porque atrapa a casi todo el mundo. Tienes products (la lista completa) y tienes query (lo que el usuario escribió). Quieres mostrar la lista filtrada. El instinto dice: "creo un tercer estado, filteredProducts, y lo mantengo al día". Es un error, y de los caros. La lista filtrada no es una cosa nueva que memorizar: es, literalmente, products.filter(coincide con query). Es una función de dos datos que ya tienes. No la guardas: la computas en el cuerpo del componente, en cada render, con una sola línea. Ese es el hábito que instala este módulo, y el que separa el código React que se mantiene solo del que se llena de bugs raros.

Conexión con el módulo. Este es el módulo del estado derivado, y su caso concreto son las listas: el ProductList de Mercado que se filtra por la búsqueda, se filtra por categoría y se ordena por precio. Todo eso se deriva. Trabajamos seis ideas: estado vs derivado (la pregunta que decide cuál es cuál), derivar en el render (la mecánica del products.filter().sort()), por qué guardar el derivado se desincroniza (medido en Node), filtrar y ordenar el ProductList (el caso de Mercado completo), la key cuando la lista cambia (saldamos la deuda del módulo 2), y la decisión (el procedimiento, más los dos anti-patrones que hay que evitar). Lo que no toca este módulo: el estado global y los datos del servidor —cuando el catálogo llega de una API, no de una constante— es la guía frontend-state-and-data; y memoizar un cálculo caro con useMemo es la guía de performance. Aquí derivamos en el render, a secas, que es lo correcto por defecto.

Y la promesa de siempre, que se cumple cada lección: nada se cita de memoria, todo se ejecuta. La lógica de derivar —filtrar, ordenar, sumar— es JavaScript puro, así que la corremos en Node con datos fijos y ponemos su salida literal en los bloques "Qué esperar". El JSX real —cómo se ve el ProductList que usa la lista derivada— se muestra en los bloques de código, porque es la API que escribirás. Lo ejecutable es la lógica; lo mostrado es la sintaxis.

Una analogía: tu edad no se anota, se calcula

Imagina que alguien te pregunta tu edad y, para no tener que pensarla, decides anotarla en un papel: "tengo 30 años". Lo pegas en la pared. Funciona… hoy. El problema es que tu edad no es un dato independiente: es una función de tu fecha de nacimiento y de la fecha de hoy. En el momento en que pasa tu cumpleaños, el papel miente: dice 30 cuando ya tienes 31. Y no hay forma de que el papel se entere solo; alguien tiene que acordarse de ir a tacharlo y reescribirlo. Cada dato que se puede calcular y que en vez de eso se anota se convierte, tarde o temprano, en un papel que miente.

La alternativa es obvia una vez que la ves: no anotes la edad; guarda solo la fecha de nacimiento, y calcula la edad cada vez que alguien pregunte. La fecha de nacimiento sí es un dato de verdad —no se deduce de nada, es lo que es—. La edad se deriva de ella: edad = hoy − fecha de nacimiento. Calculada así, nunca está desactualizada, porque se produce fresca en el momento de preguntar. No hay papel que tachar, no hay nada que sincronizar, no hay forma de que mienta.

Lo mismo pasa con el total de un recibo en el supermercado. La cajera no lleva un número "total" apuntado que va corrigiendo a mano con cada producto —sería absurdo y se equivocaría—. El total se recalcula sumando los precios cada vez. Los productos escaneados son el dato; el total se deriva de ellos. Si agregas un producto, el total cambia solo, porque es una consecuencia, no una anotación.

En React es exactamente igual. El query, el cart, la lista products son tu "fecha de nacimiento": datos de verdad, que cambian por sí mismos, que guardas en estado. La lista filtrada, el conteo, el total, "¿hay resultados?" son tu "edad": consecuencias que se calculan en cada render y por eso nunca mienten. Guarda la fecha, deriva la edad. Guarda el query, deriva la lista visible.

El caso que nos acompaña: el ProductList de Mercado

Seguimos con el storefront de Mercado —el frontend de la tienda—, y esta vez el foco es la pieza que más se deriva: el ProductList. Lo que el usuario ve en pantalla no es la lista completa de productos tal cual: es la lista filtrada por lo que escribió en la búsqueda, filtrada por la categoría que eligió, y ordenada por precio. Esa lista visible es el resultado de tres transformaciones encadenadas sobre los datos, y ninguna de las tres se guarda: se computan juntas, en el render, con una función que usaremos todo el módulo:

function getVisibleProducts(products, query, category, sort) {
  const q = query.trim().toLowerCase();
  return products
    .filter((p) => p.name.toLowerCase().includes(q))               // por búsqueda
    .filter((p) => category === 'all' || p.category === category)  // por categoría
    .sort((a, b) =>
      sort === 'price-desc' ? b.priceCents - a.priceCents : a.priceCents - b.priceCents
    );
}

Recuerda los datos. Un producto (Product) tiene id, name, priceCents (el precio en centavos, como entero: 2599), category e inStock. El precio se formatea al mostrarlo con formatPrice(2599)"$25.99". El estado fuente —lo que sí se guarda— es pequeño: el query de la búsqueda, la category elegida, el sort (orden). Y products, que en una app real llegaría de una API (eso es frontend-state-and-data), aquí lo tratamos como un dato fijo.

Guarda este mapa mental, porque es la forma del módulo entero:

   ESTADO FUENTE                         DERIVADO (se computa en el render)
   (se guarda, cambia solo)              ┌───────────────────────────────────┐
   ┌──────────────┐                      │ visible   = getVisibleProducts(...) │
   │ products     │──────────┐           │ count     = visible.length          │
   │ query        │──────────┼──────────▶│ total     = suma de precios          │
   │ category     │──────────┤           │ hasResults= count > 0                │
   │ sort         │──────────┘           │ isEmpty   = count === 0              │
   └──────────────┘                      └───────────────────────────────────┘
        cuatro datos                          todo esto NO se guarda

A la izquierda, lo que guardas: cuatro datos que cambian por acción del usuario. A la derecha, lo que no guardas: todo lo que se puede calcular de esos cuatro. La flecha va en un solo sentido —de la fuente al derivado—, y nunca al revés. Ese es el hábito.

Ejemplo trabajado: la lista visible, derivada de punta a punta

Nada convence como verlo. Vamos a ejecutar getVisibleProducts sobre los productos de Mercado, con distintos query y orden, y a derivar en el mismo paso el conteo y el total —para que quede claro que "derivado" no es solo la lista, sino todo lo que se calcula de ella—. Nadie guarda estos resultados en ningún lado: se producen frescos en cada llamada, que es exactamente lo que pasa en cada render.

'use strict';
// La lista visible + el conteo + el total se DERIVAN del estado fuente.

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

const products = [
  { 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 },
];

// getVisibleProducts: una función PURA. Toma el estado fuente y COMPUTA la
// lista visible. No guarda nada; la recalcula cada vez que se la llama.
function getVisibleProducts(products, query, sort) {
  const q = query.trim().toLowerCase();
  return products
    .filter((p) => p.name.toLowerCase().includes(q)) // filtra por la búsqueda
    .sort((a, b) =>
      sort === 'price-desc' ? b.priceCents - a.priceCents : a.priceCents - b.priceCents
    );
}

function render(query, sort) {
  // TODO lo de abajo se DERIVA en cada render; nada se guarda en otro estado.
  const visible = getVisibleProducts(products, query, sort);
  const count = visible.length;
  const totalCents = visible.reduce((sum, p) => sum + p.priceCents, 0);

  console.log(`estado:    query=${JSON.stringify(query)}  sort=${JSON.stringify(sort)}`);
  console.log(`derivado:  ${count} visibles, total ${formatPrice(totalCents)}`);
  for (const p of visible) console.log(`  - ${p.name}  ${formatPrice(p.priceCents)}`);
  console.log('');
}

console.log('=== la lista visible se DERIVA del estado (query, sort) ===\n');
render('', 'price-asc');
render('e', 'price-asc');
render('e', 'price-desc');

En este ejemplo del módulo introductorio dejamos getVisibleProducts con dos parámetros (query y sort) para no adelantar la categoría; la versión con category es la que usaremos a partir de la lección 5. Y así se vería el ProductList en React de verdad, usando esa lista derivada —fíjate en que la variable visible se calcula en el cuerpo y luego se recorre con .map(), con key={p.id}—:

function ProductList({ products, query, sort }) {
  const visible = getVisibleProducts(products, query, sort); // DERIVADO en el render

  if (visible.length === 0) {
    return <p className="empty">No products match your search.</p>;
  }

  return (
    <ul className="product-list">
      {visible.map((p) => (
        <ProductCard key={p.id} name={p.name} priceCents={p.priceCents} />
      ))}
    </ul>
  );
}

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

=== la lista visible se DERIVA del estado (query, sort) ===

estado:    query=""  sort="price-asc"
derivado:  5 visibles, total $214.97
  - Desk Lamp  $19.99
  - Wireless Mouse  $25.99
  - USB-C Hub  $34.99
  - Laptop Stand  $45.00
  - Mechanical Keyboard  $89.00

estado:    query="e"  sort="price-asc"
derivado:  3 visibles, total $134.98
  - Desk Lamp  $19.99
  - Wireless Mouse  $25.99
  - Mechanical Keyboard  $89.00

estado:    query="e"  sort="price-desc"
derivado:  3 visibles, total $134.98
  - Mechanical Keyboard  $89.00
  - Wireless Mouse  $25.99
  - Desk Lamp  $19.99

Lee la salida como tres renders del ProductList. En el primero, el estado es query="" (sin búsqueda) y sort="price-asc": la lista visible son los cinco productos ordenados de barato a caro, con un total de $214.97 y 5 visibles. Nadie escribió ese "5", ni ese total, ni ese orden: los tres se derivaron de products en el momento.

En el segundo, el estado cambió a query="e". Ahora la lista visible son solo los tres productos cuyo nombre contiene una "e" —Desk Lamp, Wireless Mouse, Mechanical Keyboard—; USB-C Hub y Laptop Stand, que no tienen "e", desaparecieron. El conteo bajó a 3 y el total a $134.98, otra vez solos, como consecuencia del filtro. No tocamos ninguna variable "count" ni "total": cambiamos el estado fuente (query), y todo lo derivado se recalculó.

En el tercero, el query sigue siendo "e" pero el sort cambió a "price-desc": la misma lista de tres productos, pero ahora de caro a barato. Los productos son idénticos —el conteo (3) y el total ($134.98) no cambian, porque son los mismos tres—, pero el orden se invirtió. Eso es lo que significa "derivar en el render": cambias un dato de la fuente (el orden), y la vista se rehace entera a partir de la fuente, sin que tú administres la diferencia a mano.

Detente en lo esencial: en ningún momento guardamos la lista visible, ni el conteo, ni el total en una variable persistente. Cada uno se produjo fresco a partir de products, query y sort. Como la edad que se calcula de la fecha de nacimiento, estos valores no pueden estar desactualizados, porque no se almacenan: se derivan. Esa es la idea que todo el módulo va a profundizar.

El mapa del módulo

Guarda esta ruta; es cómo cada lección construye el hábito de derivar:

Tema                              Lección   Idea clave
────────────────────────────────  ────────  ──────────────────────────────────────────
Estado o derivado                 L2        ¿puedo computarlo del estado que ya tengo?
                                            sí -> derivado (no lo guardo); no -> estado
Derivar en el cuerpo              L3        const visible = products.filter().sort()
                                            corre en cada render; filter copia, no muta
Por qué guardar se desincroniza   L4        guardar el derivado = dos fuentes de verdad;
                                            se mide la copia stale vs el derivado correcto
Filtrar y ordenar el ProductList  L5        getVisibleProducts(products, query,
                                            category, sort): el caso de Mercado completo
La key cuando la lista cambia     L6        al filtrar/reordenar cambian orden y miembros;
                                            key = id sigue al dato, key = índice se rompe
La decisión (y useMemo)           L7        el procedimiento; no sincronices con effect;
                                            useMemo solo si el cálculo es caro (remite)
────────────────────────────────  ────────  ──────────────────────────────────────────
Deriva el ProductList de Mercado  L8        el mini-proyecto, ejecutado

La frontera: qué NO entra en este módulo

Saber la frontera te evita esperar cosas que llegan después —y confundir "derivar" con temas que se le parecen—.

  • El estado global y los datos del servidor son la guía frontend-state-and-data. Aquí products es una constante fija en el archivo. En una app real, esa lista llega de una API —hay que pedirla, esperarla, manejar el error, cachearla—; eso es fetching de datos y estado del servidor, un tema entero. En este módulo asumimos que ya tienes los productos y nos concentramos en derivar de ellos.
  • Memoizar un cálculo con useMemo es la guía de performance. Cuando derivas en el render, el cálculo corre en cada render. Para listas pequeñas eso es gratis, y lo mediremos en la lección 7. Si algún día el cálculo fuera caro y se repitiera sin que sus entradas cambien, useMemo lo memoiza —pero eso es una optimización posterior, no la regla por defecto—. Aquí derivamos a secas.
  • Los efectos (useEffect) son el módulo 6. En la lección 7 verás por qué no debes usar un effect para "sincronizar" un estado derivado con su fuente (llega tarde y sobra), pero el useEffect en sí —para hablar con sistemas externos— es el módulo que sigue.
  • Levantar el estado —dónde vive products o query cuando varios componentes lo tocan— es el módulo 7. Aquí asumimos que el estado ya está donde tiene que estar; nos ocupamos de derivar de él, no de ubicarlo.
  • Y todo lo que los módulos anteriores ya delimitaron sigue igual: HTML/CSS a fondoweb-fundamentals-html-css; Next.js/SSRnextjs-app-router; estilos/design systemsui-systems-and-design-implementation.

Errores comunes

Guardar en estado lo que se puede derivar. Qué pasa: al tener query, se crea un useState para filteredProducts y se intenta mantenerlo al día. Por qué pasa: se siente como "el siguiente paso natural" después de aprender useState —si un dato importa, guárdalo—. Cómo detectarlo: tienes dos piezas de estado donde una es siempre función de la otra, y empiezan a aparecer bugs donde la lista muestra algo viejo. Cómo corregirlo: pregúntate "¿puedo calcular este dato de otro estado que ya tengo?". Si sí, no es estado: derívalo en el render. Guarda solo query; computa filteredProducts con products.filter(...). Este es el error que el módulo entero desmonta.

Creer que "derivar" es más lento o más frágil que "guardar". Qué pasa: alguien evita derivar por miedo a "recalcular en cada render". Por qué pasa: suena costoso repetir un cálculo muchas veces. Cómo detectarlo: te encuentras guardando resultados "para no recalcular" antes de haber medido nada. Cómo corregirlo: para listas normales, filtrar y ordenar es tan rápido que no se nota (lo mediremos en la lección 7). Y guardar es más frágil, no menos: introduce la desincronización. Derivar es a la vez lo más simple y lo más correcto; optimizar viene después, y solo si mides que hace falta.

Confundir "estado derivado" con "estado global" o "datos del servidor". Qué pasa: al oír "no guardes la lista filtrada" alguien salta a Redux, Context o React Query. Por qué pasa: todos suenan a "manejo de datos". Cómo detectarlo: buscas una librería para algo que es una línea de .filter(). Cómo corregirlo: separa los temas. Derivar (este módulo) es computar en el render un valor a partir del estado que ya tienes —no necesita nada más que JavaScript—. Estado global y datos del servidor (otra guía) son de dónde vienen y quién es dueño de los datos fuente. Deriva primero; lo demás es otra conversación.

Ejercicios

Ejercicio 1 — Clasifica: estado o derivado. Para cada dato del storefront, di si es estado (se guarda) o derivado (se computa), y por qué: (a) el texto de la búsqueda; (b) el número de productos que coinciden con la búsqueda; (c) los items del carrito; (d) el total del carrito en centavos; (e) si el botón "Checkout" debe estar deshabilitado porque el carrito está vacío.

Ver solución
  • (a) El texto de la búsqueda — ESTADO. Lo escribe el usuario; no se deduce de ningún otro dato. Es una fuente de verdad. (const [query, setQuery] = useState('').)
  • (b) El número de coincidencias — DERIVADO. Es products.filter(coincide con query).length. Se calcula de products y query, que ya tienes. No se guarda.
  • (c) Los items del carrito — ESTADO. Los agrega y quita el usuario; no se deducen de nada. Fuente de verdad.
  • (d) El total del carrito — DERIVADO. Es la suma de precio × cantidad sobre los items del carrito. Se calcula del cart (y de los precios de products). No se guarda.
  • (e) Si "Checkout" está deshabilitado — DERIVADO. Es cart.length === 0. Un booleano que se calcula del cart en el render. No es un estado isCheckoutDisabled que haya que mantener sincronizado.

La regla que las decide todas: ¿puedo calcularlo desde otro estado que ya tengo? Si sí, es derivado.

Ejercicio 2 — Aplica la analogía. Explica, con la analogía de la edad y la fecha de nacimiento, por qué guardar filteredProducts en estado es un error. ¿Qué juega el papel de la "fecha de nacimiento" y qué el de la "edad"? ¿Qué es "el papel que miente"?

Ver solución

La "fecha de nacimiento" —el dato de verdad, que se guarda— son products y query: cambian por sí mismos y no se deducen de nada. La "edad" —la consecuencia que se calcula— es filteredProducts: es, por definición, products.filter(coincide con query), una función de los dos anteriores. Guardar filteredProducts en estado es como anotar la edad en un papel: en el instante en que cambia products o query sin que alguien se acuerde de recalcular la copia, esa copia es "el papel que miente" —dice una lista que ya no corresponde a los datos actuales—. La solución es la misma que con la edad: no anotes el resultado; guarda solo la fuente (products, query) y calcula la lista filtrada en cada render, donde nunca puede estar desactualizada porque se produce fresca.

Ejercicio 3 — Predice la derivación. Sin correr nada, usando el ejemplo trabajado, predice las tres líneas de salida (estado:, derivado: y los productos) para render('hub', 'price-asc'). Recuerda que el filtro es "el nombre contiene el texto" en minúsculas.

Ver solución
estado:    query="hub"  sort="price-asc"
derivado:  1 visibles, total $34.99
  - USB-C Hub  $34.99

El único producto cuyo nombre (en minúsculas) contiene "hub" es USB-C Hub ("usb-c hub"). Ninguno de los otros cuatro lo contiene. Así que la lista visible tiene un producto; el conteo derivado es 1; el total derivado es el precio de ese único producto, 3499 centavos = $34.99. El orden (price-asc) no cambia nada con un solo elemento. Todo se deriva de products y query: no hay ningún "1" ni "$34.99" guardado en ninguna parte.

Resumen y siguiente paso

En esta lección instalaste la idea que ordena el módulo: la mayoría de lo que la interfaz muestra no es estado, es una función del estado. La lista filtrada, el conteo, el total, "¿hay resultados?" no se guardan: se derivan en cada render a partir del estado fuente (products, query, category, sort). Lo viste ejecutado: getVisibleProducts produciendo la lista visible con distintos query y orden, y el conteo y el total calculándose solos como consecuencia —sin que guardáramos ninguno—. Y lo anclaste en la analogía: no anotes tu edad en un papel que se desactualiza; guarda tu fecha de nacimiento y calcula la edad cada vez. Guarda el query; deriva la lista.

Antes de avanzar deberías poder: distinguir estado de derivado con la pregunta "¿puedo calcularlo del estado que ya tengo?"; explicar con la analogía por qué guardar el derivado se desactualiza; y ubicar la frontera del módulo (aquí se deriva en el render; el estado global/servidor y useMemo son otras guías).

La lección 2 clava la primera pieza del mapa: estado o derivado, la pregunta que lo decide. Vas a ver, ejecutado, un clasificador que separa lo que es estado de lo que es derivado sobre los datos reales del storefront, y el cálculo de todos los valores derivados a partir de un estado mínimo —el principio de la única fuente de verdad—. Ahí, "derivar, no guardar" pasa de eslogan a criterio con el que decidir cada useState que escribas.

Recursos