Módulo 5: Derived State And Lists

Estado o derivado: la pregunta que lo decide

Descripción

Cada vez que estás por escribir un useState, hay una decisión escondida que casi nadie hace conscientemente: ¿esto de verdad merece ser estado, o lo puedo calcular de algo que ya guardo? Contestar bien esa pregunta es la mitad del arte de estructurar estado en React. Contestarla mal —guardar cosas que se podían calcular— es la causa de una clase entera de bugs, los de desincronización, que veremos medidos en la lección 4. Esta lección te da la pregunta, la regla para responderla, y el principio que la sostiene: la única fuente de verdad.

La idea es simple de enunciar y transformadora de aplicar. El estado de tu app debería ser el conjunto mínimo de datos que cambian por sí mismos y que no se pueden deducir de ningún otro. Todo lo demás —absolutamente todo lo que se pueda calcular a partir de ese mínimo— no es estado: es derivado, y se computa en el render. El query es estado (lo escribe el usuario, no se deduce de nada). La lista filtrada no lo es (es products.filter(query)). El cart es estado. El total del carrito no lo es (es la suma de sus precios). Aprender a trazar esa línea es lo que separa el estado bien diseñado del que se llena de copias que hay que mantener sincronizadas a mano.

Conexión con el módulo. Esta es la lección conceptual del módulo: instala el criterio que las siguientes cinco aplican. La lección 3 tomará "esto es derivado" y mostrará cómo se deriva en el render (products.filter().sort()); la 4 mostrará qué pasa si te equivocas y lo guardas (se desincroniza); la 5 lo aplicará al ProductList completo. Aquí solo aprendemos a decidir: dado un dato, ¿es estado o es derivado? Y lo hacemos con código ejecutado, no con reglas memorizadas: un clasificador que recorre los datos del storefront y un cálculo que produce todos los valores derivados a partir de un estado mínimo.

Una analogía: el recibo del supermercado

Vuelve a la caja del supermercado, pero mira de cerca qué datos existen ahí y cuáles se anotan frente a cuáles se calculan. Lo que la cajera realmente "guarda", lo que va cambiando conforme escanea, es la lista de productos escaneados: leche, pan, huevos, cada uno con su precio. Ese es el dato de verdad —cambia por acción (cada escaneo lo modifica) y no se deduce de nada más—. Es el equivalente al estado.

El total a pagar, en cambio, no se anota como un dato aparte que la cajera va corrigiendo a mano. Sería absurdo: tendría que acordarse de sumar el precio nuevo al total cada vez, y en el momento en que se distrajera, el total y la lista discreparían —el recibo mentiría—. Lo que hace la caja es lo contrario: el total es una consecuencia de la lista, y se recalcula sumando los precios. Agregas un producto, y el total cambia solo, porque es una función de la lista, no una anotación independiente. El total es derivado.

Y no es solo el total. El número de artículos ("15 items"), si calificas para el descuento ("compraste más de $50, llevas 10% off"), el subtotal por categoría: todos son consecuencias que se calculan de la lista escaneada. Nadie los guarda por separado. La lista es la única fuente de verdad; todo lo demás se deriva de ella en el momento de imprimir el recibo. En React, tu trabajo al diseñar estado es exactamente ese: identificar cuál es "la lista de productos escaneados" —el mínimo que se guarda— y reconocer que el resto son "totales" que se calculan.

La regla operativa: la pregunta que decide

Cuando dudes si un dato debe ir en useState, hazte una pregunta:

¿Puedo calcular este dato a partir de otro estado (o de las props) que ya tengo?

  • Si la respuesta es → es derivado. No lo guardes. Computalo en el render.
  • Si la respuesta es no → es estado. Guárdalo con useState.

Eso es todo. La pregunta funciona porque captura la diferencia esencial: un dato que se puede calcular de otros no es información nueva —es una vista de información que ya tienes—. Guardarlo no agrega nada; solo agrega una segunda copia que puede quedar fuera de sincronía con la original.

Aplícala a los datos del storefront y la línea se traza sola:

Dato                     ¿se puede calcular de otro estado?   Veredicto
───────────────────────  ───────────────────────────────────  ──────────
query                    no (lo escribe el usuario)           ESTADO
cart                     no (lo cambia el usuario)             ESTADO
products                 no (son los datos; vienen de fuera)  ESTADO*
filteredProducts         sí: products.filter(query)           DERIVADO
sortedProducts           sí: [...products].sort(...)          DERIVADO
cartCount                sí: suma de cart[].qty                DERIVADO
cartTotal                sí: suma de precio * qty              DERIVADO
hasResults               sí: filteredProducts.length > 0      DERIVADO

(*products es "estado" en el sentido de que es un dato fuente que no se deduce de nada; en una app real vive fuera del componente y llega de una API —eso es frontend-state-and-data—. Lo importante aquí es que no se computa de query ni de cart, así que no es derivado.)

Fíjate en el patrón: el estado es corto (tres datos) y lo derivado es largo (todo lo demás). Así debe ser. Cuanto más pequeño el estado, menos cosas pueden desincronizarse, menos bugs. La meta al diseñar estado no es "guardar todo lo que importa"; es guardar el mínimo del que todo lo demás se pueda deducir.

Ejemplo trabajado: el clasificador y el cálculo, ejecutados

Vamos a hacer dos cosas en Node. Primero, partir de un estado mínimo —solo products, query y cart— y derivar de él todos los valores que la interfaz necesitaría (el conteo de coincidencias, si hay resultados, el número de items del carrito, el total), para comprobar que ninguno hace falta guardarlo. Y segundo, imprimir la tabla de veredictos, para dejar la decisión hecha explícita.

'use strict';
// Cuándo algo es ESTADO (no se puede computar) y cuándo es DERIVADO (se puede).
// Regla: si puedo calcularlo desde el estado que ya tengo, es DERIVADO -> no lo guardo.

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 },
];

// EL ESTADO MÍNIMO: lo que cambia por sí mismo y no se deduce de nada más.
const state = {
  products,          // los datos (aquí fijos)
  query: 'e',        // lo que el usuario escribió (cambia por sí mismo)
  cart: [            // items del carrito (cambia por sí mismo)
    { id: 'p1', qty: 2 },
    { id: 'p3', qty: 1 },
  ],
};

// TODO ESTO SE DERIVA del estado de arriba. Ninguno necesita su propio useState.
const q = state.query.trim().toLowerCase();
const visible = state.products.filter((p) => p.name.toLowerCase().includes(q));
const visibleCount = visible.length;
const hasResults = visibleCount > 0;
const cartCount = state.cart.reduce((n, item) => n + item.qty, 0);
const priceById = new Map(state.products.map((p) => [p.id, p.priceCents]));
const cartTotalCents = state.cart.reduce(
  (sum, item) => sum + priceById.get(item.id) * item.qty,
  0
);

console.log('=== ESTADO (se guarda) vs DERIVADO (se computa) ===\n');
console.log('ESTADO minimo (la unica fuente de verdad):');
console.log('  query =', JSON.stringify(state.query));
console.log('  cart  =', JSON.stringify(state.cart));
console.log('\nDERIVADO (todo esto se calcula en el render, NO se guarda):');
console.log('  visibleCount   =', visibleCount, ' (productos que matchean la busqueda)');
console.log('  hasResults     =', hasResults);
console.log('  cartCount      =', cartCount, ' (suma de cart[].qty)');
console.log(
  '  cartTotalCents =',
  cartTotalCents,
  '(= $' + (cartTotalCents / 100).toFixed(2) + ')'
);

console.log('\n=== la pregunta que lo decide todo ===\n');
const candidates = [
  ['query (texto de busqueda)',         'ESTADO',   'lo escribe el usuario; no se deduce de nada'],
  ['cart (items + cantidades)',         'ESTADO',   'lo cambia el usuario; no se deduce de nada'],
  ['filteredProducts (lista filtrada)', 'DERIVADO', 'products.filter(query)'],
  ['cartCount (numero de items)',       'DERIVADO', 'suma de cart[].qty'],
  ['cartTotal (total en centavos)',     'DERIVADO', 'suma de precio * qty'],
  ['hasResults (hay resultados?)',      'DERIVADO', 'visible.length > 0'],
];
console.log('valor                              veredicto  se computa de');
console.log('---------------------------------  ---------  ------------------------');
for (const [name, verdict, how] of candidates) {
  console.log(name.padEnd(33), '', verdict.padEnd(9), '', how);
}

Así se vería, en React de verdad, un componente que respeta esta separación: solo query y cart son estado; todo lo demás se calcula en el cuerpo:

function Storefront({ products }) {
  const [query, setQuery] = useState('');       // ESTADO
  const [cart, setCart] = useState([]);         // ESTADO

  // DERIVADO: se computa en cada render, no se guarda
  const q = query.trim().toLowerCase();
  const visible = products.filter((p) => p.name.toLowerCase().includes(q));
  const cartCount = cart.reduce((n, item) => n + item.qty, 0);

  return (
    <div>
      <SearchBar value={query} onChange={setQuery} />
      <p>{visible.length} results</p>          {/* derivado */}
      <Cart count={cartCount} />               {/* derivado */}
      <ProductList products={visible} />
    </div>
  );
}

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

=== ESTADO (se guarda) vs DERIVADO (se computa) ===

ESTADO minimo (la unica fuente de verdad):
  query = "e"
  cart  = [{"id":"p1","qty":2},{"id":"p3","qty":1}]

DERIVADO (todo esto se calcula en el render, NO se guarda):
  visibleCount   = 3  (productos que matchean la busqueda)
  hasResults     = true
  cartCount      = 3  (suma de cart[].qty)
  cartTotalCents = 8697 (= $86.97)

=== la pregunta que lo decide todo ===

valor                              veredicto  se computa de
---------------------------------  ---------  ------------------------
query (texto de busqueda)          ESTADO     lo escribe el usuario; no se deduce de nada
cart (items + cantidades)          ESTADO     lo cambia el usuario; no se deduce de nada
filteredProducts (lista filtrada)  DERIVADO   products.filter(query)
cartCount (numero de items)        DERIVADO   suma de cart[].qty
cartTotal (total en centavos)      DERIVADO   suma de precio * qty
hasResults (hay resultados?)       DERIVADO   visible.length > 0

Lee la primera mitad. El estado mínimo son dos cosas: query = "e" y un cart con dos items. Eso es todo lo que se guarda. De ahí, la segunda mitad calcula cuatro valores sin guardar ninguno: visibleCount = 3 (los tres productos con "e" en el nombre), hasResults = true (porque 3 > 0), cartCount = 3 (2 unidades de p1 más 1 de p3), y cartTotalCents = 8697 (el Wireless Mouse a 2599 × 2, más el USB-C Hub a 3499 × 1: 5198 + 3499 = 8697, es decir $86.97). Ninguno de esos cuatro números existe como un useState; todos son consecuencias del estado mínimo.

Ese es el punto entero: con query y cart guardados, ya tienes todo. Agregar un useState para visibleCount o para cartTotal no te daría información nueva —solo una copia que habría que recalcular cada vez que cart o query cambien, y que mentiría si te olvidas—. La segunda mitad, la tabla de veredictos, deja la decisión explícita: dos ESTADO (lo que el usuario cambia directamente) y cuatro DERIVADO (lo que se computa de ellos). Cada "DERIVADO" trae en la última columna cómo se computa, y ninguna de esas fórmulas necesita más que el estado mínimo.

Profundización: la única fuente de verdad

El principio detrás de todo esto tiene nombre: single source of truth (única fuente de verdad). Para cada pieza de información en tu app, debe haber un solo lugar que la posee. Si un dato se puede calcular de otro, el "dueño" es el original; la versión calculada no es una segunda fuente, es una vista del original, producida al vuelo.

¿Por qué importa tanto? Porque dos fuentes para el mismo dato pueden discrepar, y cuando discrepan, tu UI está en un estado imposible: dice dos cosas distintas sobre la misma realidad. Si guardas cart y también cartCount, existe un instante —después de agregar un item pero antes de actualizar el contador— en que cart tiene 3 items y cartCount dice 2. Ese instante es un bug esperando a pasar. Si cartCount se deriva de cart en el render, ese instante no puede existir: no hay dos números, hay uno solo que se calcula del cart actual, siempre.

De ahí sale la meta de diseño: minimiza el estado. No es que "menos estado" sea más elegante (aunque lo es); es que cada pieza de estado es una fuente de verdad más que puede desincronizarse de las otras. El estado mínimo —el conjunto irreducible de datos que cambian por sí mismos— tiene la menor cantidad posible de formas de estar mal. Todo lo que puedas mover de "estado" a "derivado" es un bug potencial que eliminas por construcción.

Una señal práctica de que estás guardando de más: si al cambiar un estado tienes que acordarte de actualizar otro para que "cuadren", esos dos no debían ser dos estados. Uno de ellos es derivado del otro. La regla "si cambio A tengo que actualizar B" es el olor de la desincronización; la cura es derivar B de A en vez de guardarlo.

Errores comunes

Copiar una prop en estado "por si acaso". Qué pasa: un componente recibe products por props y hace const [items, setItems] = useState(products). Por qué pasa: se piensa "lo guardo en estado para poder trabajarlo". Cómo detectarlo: cuando el padre cambia products, tu componente sigue mostrando la lista vieja —porque useState(props.x) solo toma el valor inicial; después ignora la prop—. Cómo corregirlo: si solo vas a mostrar o filtrar la prop, úsala directo (products), no la copies en estado. Copiar una prop en estado crea justo dos fuentes de verdad: la prop del padre y la copia local, que se desincronizan. (Solo es válido copiarla si de verdad es un valor editable que arranca desde la prop y luego diverge por acción del usuario —y aun así, con cuidado—.)

Guardar un booleano que es una condición. Qué pasa: se crea const [hasResults, setHasResults] = useState(false) y se actualiza a mano cada vez que cambia la búsqueda. Por qué pasa: "si la UI depende de si hay resultados, guardemos ese booleano". Cómo detectarlo: hay que llamar a setHasResults en varios lugares y alguno se olvida, dejando el booleano mintiendo. Cómo corregirlo: hasResults es una condición sobre datos que ya tienes: const hasResults = visible.length > 0, calculado en el render. Los booleanos de "¿está vacío?", "¿está deshabilitado?", "¿hay error?" (cuando el error es un dato) casi siempre son derivados, no estado.

Confundir "lo necesito en el render" con "es estado". Qué pasa: como el total se muestra en pantalla, se asume que debe ser estado. Por qué pasa: se mezcla "este valor aparece en la UI" con "este valor se guarda". Cómo detectarlo: tienes estados que solo se leen para mostrar y que siempre se calculan de otros. Cómo corregirlo: que un valor aparezca en el render no lo hace estado; casi todo lo que aparece en el render es derivado. La pregunta no es "¿lo uso en el JSX?" (casi todo se usa) sino "¿lo puedo calcular de otro estado?". Si sí, se deriva en el render y se usa ahí mismo, sin useState.

Ejercicios

Ejercicio 1 — Traza la línea. De esta lista de datos de un formulario de checkout, separa cuáles son estado y cuáles derivado, con su fórmula: (a) el nombre que el usuario teclea; (b) si el nombre es válido (no vacío); (c) los items del carrito; (d) el subtotal; (e) el impuesto (16% del subtotal); (f) el total (subtotal + impuesto); (g) si el botón "Pay" está habilitado (nombre válido y carrito no vacío).

Ver solución
  • (a) Nombre — ESTADO. Lo teclea el usuario; no se deduce de nada. (useState('').)
  • (b) Nombre válido — DERIVADO. name.trim().length > 0. Condición sobre name.
  • (c) Items del carrito — ESTADO. Los cambia el usuario; fuente de verdad.
  • (d) Subtotal — DERIVADO. Suma de precio × qty sobre el carrito.
  • (e) Impuesto — DERIVADO. Math.round(subtotal * 0.16). Se calcula del subtotal (que a su vez se deriva del carrito).
  • (f) Total — DERIVADO. subtotal + impuesto. Se calcula de los anteriores.
  • (g) Botón "Pay" habilitado — DERIVADO. nombreVálido && cart.length > 0. Booleano sobre datos derivados/estado.

Solo dos son estado (name, cart). Los otros cinco se derivan en cadena: del carrito sale el subtotal, del subtotal el impuesto y el total, y de todo junto el botón. Guardar cualquiera de esos cinco crearía una copia que habría que sincronizar a mano.

Ejercicio 2 — Encuentra el estado de más. Este componente tiene un useState que no debería existir. Encuéntralo, explica por qué es derivado, y reescribe el componente sin él.

function SearchResults({ products }) {
  const [query, setQuery] = useState('');
  const [count, setCount] = useState(0);

  function handleSearch(text) {
    setQuery(text);
    setCount(products.filter((p) => p.name.includes(text)).length);
  }

  return (
    <div>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      <p>{count} results</p>
    </div>
  );
}
Ver solución

El estado de más es count. Es derivado: es products.filter(coincide con query).length, una función de products y query, que ya tienes. Guardarlo obliga a recalcularlo y llamar a setCount en cada búsqueda —y si mañana products cambia sin pasar por handleSearch, count queda stale—. Además crea el riesgo clásico: handleSearch calcula el count con text y con el products actual, pero son dos setters (setQuery, setCount) que hay que mantener a la par.

Sin él, derivando en el render:

function SearchResults({ products }) {
  const [query, setQuery] = useState('');

  const visible = products.filter((p) => p.name.includes(query)); // DERIVADO

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <p>{visible.length} results</p>
    </div>
  );
}

Ahora hay una fuente de verdad (query), el count se calcula de la lista derivada en el render, y no hay nada que sincronizar: cambie lo que cambie (query o products), el número siempre corresponde a la realidad.

Ejercicio 3 — El olor de la desincronización. Un compañero dice: "guardo cart y también cartTotal; cada vez que agrego algo al carrito, actualizo los dos". Explica, sin usar la palabra "performance", por qué eso es un problema, y cuál es la señal que delata que cartTotal no debía ser estado.

Ver solución

El problema es la desincronización: cart y cartTotal son dos fuentes de verdad para el mismo hecho (cuánto cuesta el carrito). Como hay que actualizarlos por separado, existe la posibilidad —y con el tiempo, la certeza— de que discrepen: un camino de código que modifique cart y se olvide de recalcular cartTotal (o lo calcule mal) deja al carrito diciendo dos cosas distintas sobre su propio precio. La UI entra en un estado imposible.

La señal que lo delata es justamente lo que el compañero describe como su rutina: "cada vez que cambio cart, tengo que acordarme de actualizar cartTotal". Esa dependencia obligatoria —"si toco A, debo tocar B"— es el olor inconfundible de que B es derivado de A. La cura es no guardar cartTotal: calcularlo en el render con cart.reduce(...). Así solo hay un dato que cambia (cart), el total es una consecuencia, y es imposible que discrepen porque no son dos cosas.

Resumen y siguiente paso

En esta lección aprendiste a decidir qué es estado y qué es derivado con una sola pregunta: ¿puedo calcular este dato a partir de otro estado que ya tengo? Si sí, no se guarda —se deriva en el render—; si no, es estado. Lo sostiene el principio de la única fuente de verdad: cada dato tiene un solo dueño, y todo lo que se pueda calcular de él es una vista, no una segunda copia. Y el objetivo de diseño que se sigue: minimizar el estado, porque cada pieza de estado es una fuente que puede desincronizarse de las otras. Lo viste ejecutado: de un estado mínimo (query, cart) salieron cuatro valores derivados sin guardar ninguno, y la tabla de veredictos dejó la decisión explícita.

Antes de avanzar deberías poder: aplicar la pregunta a cualquier dato y clasificarlo; explicar el principio de la única fuente de verdad y por qué dos fuentes se desincronizan; y reconocer el "olor" de la desincronización (tener que actualizar B cada vez que cambia A).

La lección 3 toma la clase "derivado" y muestra la mecánica: cómo se deriva de verdad en el cuerpo del componente con const visible = products.filter(...).sort(...), por qué .filter() te da un array nuevo (y por eso .sort() encadenado no muta la fuente), y cómo esa variable fluye al JSX. Ahí "derivar" pasa de decisión a línea de código que corre en cada render.

Recursos