Módulo 5: Derived State And Lists

Mini-proyecto: deriva el `ProductList` de Mercado

Descripción

Es hora de juntar las siete piezas del módulo en un solo lugar que funcione. En los módulos anteriores construiste el storefront estático (M1), lo describiste con JSX (M2), le agregaste estado (M3) y lo conectaste a los eventos (M4). En este mini-proyecto construyes la pieza que da vida al catálogo: un ProductList que se busca, se filtra por categoría y se ordena, con todo lo visible derivado del estado fuente, y con key = id estable para que el estado local sobreviva a cada filtrado y cada reordenamiento.

El entregable son tres cosas, como siempre: el árbol de componentes (dónde vive el estado, qué se deriva, qué baja por props), el código React real de cada pieza, y la lógica ejecutada en Node que demuestra que todo encaja —una sesión completa de uso, la comprobación de que el derivado no se desincroniza cuando cambia el catálogo, y la key estable conservando el estado local al reordenar—.

Conexión con el módulo. Esta es la lección de integración: cada concepto que viste aislado aparece aquí trabajando junto a los demás. Decidiste qué es estado y qué derivado (L2); derivaste en el cuerpo con filter().sort() (L3); evitaste la desincronización de guardar (L4); aplicaste el filtrado por categoría y el estado vacío (L5); pusiste key = id para que el estado local sobreviva (L6); y respetaste el procedimiento —derivar en el render, sin effects ni memo innecesarios— (L7). Y marca la frontera con lo que sigue: aquí products es una constante; cuando llegue de una API será estado del servidor (frontend-state-and-data). Aquí el estado ya está en App; cómo y por qué sube el estado compartido es levantar el estado (módulo 7). Y el efecto que "carga" los productos es el módulo 6. Este proyecto es "el catálogo derivado, bien hecho".

Qué vas a construir

Un ProductList de Mercado con tres controles y una lista derivada:

  1. La SearchBar con el estado query: el texto de búsqueda.
  2. El CategoryFilter con el estado category: la categoría elegida ('all', 'peripherals', 'furniture').
  3. El SortControl con el estado sort: el orden ('price-asc', 'price-desc').
  4. El ProductList que recibe products y los tres criterios, y deriva la lista visible con getVisibleProducts, mostrando el estado vacío cuando no hay resultados.

El estado fuente son tres criterios (query, category, sort) más products. Todo lo demás —la lista visible, el conteo, el total, "¿está vacío?"— se deriva. Nada de listas guardadas, nada de contadores sincronizados a mano.

El árbol de componentes

El estado vive en App; los criterios bajan a los controles y a ProductList por props; la lista visible se deriva dentro de ProductList (o en App y baja ya derivada —aquí la derivamos en ProductList—). Marcamos con [estado] dónde vive cada pieza fuente y con (derivado) lo que se computa:

flowchart TD
    App["App<br/>[estado: query, category, sort]<br/>(datos: products)"]
    App --> SB["SearchBar<br/>value=query"]
    App --> CF["CategoryFilter<br/>value=category"]
    App --> SC["SortControl<br/>value=sort"]
    App --> PL["ProductList<br/>(derivado: visible = getVisibleProducts)"]
    PL --> PC1["ProductCard key=id"]
    PL --> PC2["ProductCard key=id"]
    PL --> PC3["ProductCard key=id"]
    SB -. onChange query .-> App
    CF -. onChange category .-> App
    SC -. onChange sort .-> App

Las flechas continuas son props bajando (los criterios y los productos). Las punteadas son los eventos subiendo (módulo 4): cada control avisa a App su cambio, App actualiza el estado, y en el re-render ProductList deriva la lista nueva. El estado fuente está en un solo lugar (App); la lista visible no está guardada en ninguna parte, se computa.

El código React real

Así se escribe el ProductList derivado, en React de verdad. Estúdialo pieza por pieza; es la síntesis del módulo.

La función pura que deriva la lista visible —búsqueda, categoría, orden— vive fuera del componente para poder reusarla y testearla:

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
    );
}

El App posee el único estado fuente (los tres criterios) y lo reparte por props:

function App({ products }) {
  const [query, setQuery] = useState('');          // ESTADO
  const [category, setCategory] = useState('all');  // ESTADO
  const [sort, setSort] = useState('price-asc');    // ESTADO

  return (
    <div className="storefront">
      <SearchBar value={query} onChange={setQuery} />
      <CategoryFilter value={category} onChange={setCategory} />
      <SortControl value={sort} onChange={setSort} />
      <ProductList products={products} query={query} category={category} sort={sort} />
    </div>
  );
}

El ProductList recibe los datos y los criterios, deriva la lista visible en el cuerpo, maneja el estado vacío, y renderiza con key={p.id}:

function ProductList({ products, query, category, sort }) {
  const visible = getVisibleProducts(products, query, category, sort); // DERIVADO
  const total = visible.reduce((s, p) => s + p.priceCents, 0);          // DERIVADO

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

  return (
    <div>
      <p className="summary">{visible.length} products · {formatPrice(total)}</p>
      <ul className="product-list">
        {visible.map((p) => (
          <ProductCard key={p.id} product={p} /> // key = id: el estado local sigue al producto
        ))}
      </ul>
    </div>
  );
}

La SearchBar es un input controlado (módulo 4); su value sale del estado query que vive en App:

function SearchBar({ value, onChange }) {
  return (
    <input
      className="search-bar"
      placeholder="Search products…"
      value={value}
      onChange={(e) => onChange(e.target.value)}
    />
  );
}

Nota tres cosas que resumen el módulo. Primero: el estado es corto —tres criterios—; todo lo visible (visible, total, "¿vacío?") se deriva, nada se guarda. Segundo: la derivación es una función pura (getVisibleProducts) llamada en el cuerpo, que corre en cada render. Tercero: la key es el id del producto, no el índice —lo que mantiene el estado local pegado al producto correcto cuando la lista se filtra o se reordena—.

Ejemplo trabajado: el ProductList derivado, ejecutado

Ejecutamos la lógica completa: una sesión de uso (teclear, filtrar por categoría, reordenar), la comprobación de que el derivado se mantiene correcto cuando cambia el catálogo, y la key estable conservando el estado local al reordenar.

'use strict';
// El ProductList derivado de Mercado, de punta a punta.
//   1) una sesión: el usuario teclea, filtra por categoría y reordena
//   2) la lista se recalcula sola cuando cambian los productos (no se desincroniza)
//   3) la 'key' estable mantiene el estado local al reordenar

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

let 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 único estado fuente del storefront. Todo lo visible se DERIVA de aquí.
const state = { query: '', category: 'all', sort: 'price-asc' };

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

let renderCount = 0;
function render() {
  renderCount++;
  const visible = getVisibleProducts(products, state.query, state.category, state.sort);
  const total = visible.reduce((s, p) => s + p.priceCents, 0);
  console.log(
    `render #${renderCount}  query="${state.query}" category=${state.category} sort=${state.sort}` +
    `  ->  ${visible.length} visibles, total ${formatPrice(total)}`
  );
  console.log('   ' + (visible.map((p) => p.name + ' ' + formatPrice(p.priceCents)).join(' | ') || '(vacío)'));
}

console.log('=== 1) una sesión: cada acción cambia el ESTADO; la lista se DERIVA ===');
render();                                  // montaje
state.query = 'e';       render();         // teclea "e"
state.sort = 'price-desc'; render();       // reordena a desc
state.category = 'peripherals'; render();  // filtra por categoría
state.query = '';        render();         // limpia la búsqueda

console.log('\n=== 2) cambian los productos: el derivado NO se desincroniza ===');
// Llega un producto nuevo que matchea la categoría peripherals.
products = [...products, { id: 'p6', name: 'Webcam', priceCents: 5900, category: 'peripherals', inStock: true }];
render(); // mismo estado (query="" category=peripherals sort=desc); aparece la Webcam sola

console.log('\n=== 3) la key estable conserva el estado local al reordenar ===');
// El usuario marca "Wireless Mouse" en la lista peripherals asc; luego reordena.
const listAsc  = getVisibleProducts(products, '', 'peripherals', 'price-asc');
const listDesc = getVisibleProducts(products, '', 'peripherals', 'price-desc');
function reconcileByKey(oldRows, newList, keyOf) {
  const byKey = new Map(oldRows.map((r) => [r.key, r]));
  return newList.map((p, i) => {
    const key = keyOf(p, i);
    const prev = byKey.get(key);
    return { key, product: p, checked: prev ? prev.checked : false };
  });
}
const marked = listAsc.map((p) => ({ key: p.id, product: p, checked: p.name === 'Wireless Mouse' }));
const after = reconcileByKey(marked, listDesc, (p) => p.id);
console.log('con key = product.id, tras reordenar a desc:');
for (const r of after) console.log(`   [${r.checked ? 'x' : ' '}] ${r.product.name}`);

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

=== 1) una sesión: cada acción cambia el ESTADO; la lista se DERIVA ===
render #1  query="" category=all sort=price-asc  ->  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
render #2  query="e" category=all sort=price-asc  ->  3 visibles, total $134.98
   Desk Lamp $19.99 | Wireless Mouse $25.99 | Mechanical Keyboard $89.00
render #3  query="e" category=all sort=price-desc  ->  3 visibles, total $134.98
   Mechanical Keyboard $89.00 | Wireless Mouse $25.99 | Desk Lamp $19.99
render #4  query="e" category=peripherals sort=price-desc  ->  2 visibles, total $114.99
   Mechanical Keyboard $89.00 | Wireless Mouse $25.99
render #5  query="" category=peripherals sort=price-desc  ->  3 visibles, total $149.98
   Mechanical Keyboard $89.00 | USB-C Hub $34.99 | Wireless Mouse $25.99

=== 2) cambian los productos: el derivado NO se desincroniza ===
render #6  query="" category=peripherals sort=price-desc  ->  4 visibles, total $208.98
   Mechanical Keyboard $89.00 | Webcam $59.00 | USB-C Hub $34.99 | Wireless Mouse $25.99

=== 3) la key estable conserva el estado local al reordenar ===
con key = product.id, tras reordenar a desc:
   [ ] Mechanical Keyboard
   [ ] Webcam
   [ ] USB-C Hub
   [x] Wireless Mouse

Esta salida es el módulo entero funcionando junto. Vamos por sus tres partes.

La sesión (renders #1 a #5) muestra el estado fuente cambiando de a un criterio y la lista visible derivándose en cada render. En el montaje (#1), sin filtros: los 5 productos de barato a caro, total $214.97. El usuario teclea "e" (#2): la lista se deriva a los 3 con "e" en el nombre, total $134.98 —USB-C Hub y Laptop Stand desaparecieron—. Reordena a descendente (#3): los mismos 3 productos (conteo y total idénticos), pero de caro a barato. Filtra por categoría peripherals (#4): de esos 3, solo los 2 de peripherals (Mechanical Keyboard, Wireless Mouse); Desk Lamp, que es furniture, se cae. Limpia la búsqueda (#5): con query="" y category=peripherals, reaparecen los 3 peripherals, ordenados desc. Cinco acciones, cinco derivaciones: nunca guardamos una lista, la recalculamos entera del estado en cada paso.

El catálogo cambia (#6), y el derivado no se desincroniza. Con el mismo estado del render #5 (query="" category=peripherals sort=desc), llega un producto nuevo, "Webcam" (peripherals, $59.00). Sin tocar ningún criterio ni recalcular ninguna copia a mano, el siguiente render deriva 4 visibles e inserta la Webcam en su lugar correcto por precio (entre el teclado a $89.00 y el Hub a $34.99), con el total actualizado a $208.98. Esto es lo que la lección 4 demostró al revés: como la lista se deriva de products, cambiar products se refleja solo. Si hubiéramos guardado la lista visible en estado, la Webcam no habría aparecido hasta re-sincronizar a mano —el bug de la desincronización—. Derivar lo evita por construcción.

La key estable conserva el estado local al reordenar (parte 3). En la lista peripherals ordenada ascendente, el usuario marca "Wireless Mouse". Al reordenar a descendente, con key = product.id la marca sigue a "Wireless Mouse" hasta su nueva posición (la última, por ser el más barato de los cuatro) —[x] Wireless Mouse—, mientras los demás quedan sin marca. El estado local viajó con el producto, no con la posición, porque la key es el id. Con el índice, como viste en la lección 6, la marca habría saltado al producto equivocado. La lista se reordena en cada interacción; la key estable es lo que mantiene el estado en su sitio.

Checklist del proyecto

Antes de dar por terminado el mini-proyecto, verifica que aplicaste bien cada concepto:

  • Estado o derivado (L2): ¿el estado son solo los criterios (query, category, sort) y products? ¿Nada de listas o contadores guardados que se puedan computar?
  • Derivar en el render (L3): ¿la lista visible es una variable calculada en el cuerpo (const visible = getVisibleProducts(...)), no un useState? ¿La cadena filter().filter().sort() no muta products (el filter copia antes del sort)?
  • No desincronizar (L4): ¿no guardaste filteredProducts? ¿Comprobaste que al cambiar products la lista se actualiza sola?
  • Filtrar y ordenar (L5): ¿los tres criterios se aplican encadenados? ¿Manejas el estado vacío ("No products match your search") como derivado?
  • La key (L6): ¿cada ProductCard usa key={p.id}, no el índice? ¿Verificaste que el estado local sobrevive al filtrar y al reordenar?
  • El procedimiento (L7): ¿derivas en el render sin useEffect que sincronice, y sin useMemo que no hayas medido?

Si las seis casillas están marcadas, integraste el módulo.

Errores comunes

Guardar la lista visible en estado "para el proyecto". Qué pasa: al armar el ProductList, se crea un useState para visible y se actualiza en cada handler. Por qué pasa: se siente que "la lista que se muestra" debe vivir en algún estado. Cómo detectarlo: tienes que llamar a setVisible desde tres handlers distintos, y al agregar un producto por fuera (como la Webcam del render #6) la lista no se actualiza. Cómo corregirlo: visible es derivado —una variable en el cuerpo, getVisibleProducts(...)—, no estado. El estado son los criterios; la lista es la consecuencia. Esto es exactamente lo que el módulo entero enseña; no lo abandones en el proyecto.

Poner el estado en el componente equivocado. Qué pasa: se pone query dentro de la SearchBar y sort dentro del SortControl, y luego ProductList no puede leerlos para derivar. Por qué pasa: "cada control guarda lo suyo". Cómo detectarlo: ProductList necesita query, category y sort pero no los tiene, porque viven repartidos en los controles. Cómo corregirlo: el estado que varios componentes necesitan vive en el ancestro común (App) y baja por props —los controles lo reciben para mostrarlo y avisan sus cambios hacia arriba—. El por qué y el cómo de esto es levantar el estado, el módulo 7; aquí basta con ponerlo en App.

Usar el índice como key porque "es un proyecto pequeño". Qué pasa: visible.map((p, i) => <ProductCard key={i} />). Por qué pasa: con pocos productos parece que da igual. Cómo detectarlo: en cuanto agregas estado local a ProductCard (un checkbox, un input) y filtras o reordenas, el estado salta o se pierde —como en la lección 6—. Cómo corregirlo: key={p.id} siempre. El ProductList de este proyecto se filtra y se reordena en cada interacción; es el peor lugar posible para el índice. La key correcta cuesta lo mismo de escribir.

Ejercicios

Ejercicio 1 — Agrega el filtro "solo disponibles". Extiende el proyecto con un cuarto criterio, onlyInStock (un checkbox). Escribe: (a) el useState en App; (b) cómo se pasa a ProductList; (c) la línea que agregas a getVisibleProducts; (d) confirma que onlyInStock es estado y la lista filtrada por él sigue siendo derivada.

Ver solución

(a) En App: const [onlyInStock, setOnlyInStock] = useState(false); — es estado (un checkbox del usuario, no se deduce de nada).

(b) Se pasa a ProductList como una prop más: <ProductList products={products} query={query} category={category} sort={sort} onlyInStock={onlyInStock} />. Y a App le agregas el control: <StockFilter checked={onlyInStock} onChange={setOnlyInStock} />.

(c) En getVisibleProducts(products, query, category, sort, onlyInStock), agregas un .filter() antes del .sort():

    .filter((p) => !onlyInStock || p.inStock)   // solo disponibles (o todos, si onlyInStock=false)

(d) onlyInStock es estado: lo cambia el usuario y no se calcula de nada. La lista filtrada por él es derivada: es parte de getVisibleProducts, se computa en el render, no se guarda. Con onlyInStock = true, Mechanical Keyboard (que tiene inStock: false) desaparecería de la vista. Un criterio nuevo se suma a la cadena de derivación; no se convierte en una lista guardada.

Ejercicio 2 — Predice la sesión. Sin correr nada, usando getVisibleProducts y los 5 productos iniciales (antes de la Webcam), predice la línea de salida (render #N ... y la lista) para el estado query="a" category="furniture" sort="price-asc". Ve criterio por criterio.

Ver solución

Paso 1, búsqueda "a": nombres con "a" → Mechanical Keyboard, Laptop Stand, Desk Lamp (Wireless Mouse y USB-C Hub no tienen "a"). Quedan tres.

Paso 2, categoría "furniture": de esos tres, solo los de furniture → Laptop Stand (4500) y Desk Lamp (1999). Mechanical Keyboard es peripherals, se cae. Quedan dos.

Paso 3, orden ascendente: de barato a caro → Desk Lamp ($19.99), Laptop Stand ($45.00).

Total: 1999 + 4500 = 6499 = $64.99.

render #N  query="a" category=furniture sort=price-asc  ->  2 visibles, total $64.99
   Desk Lamp $19.99 | Laptop Stand $45.00

Cada criterio recortó o reordenó el resultado del anterior; la lista final es la consecuencia de los tres juntos, derivada.

Ejercicio 3 — Justifica el render #6. En la salida, el render #6 muestra la Webcam apareciendo en la lista sin que ningún criterio cambiara (mismo estado que el #5). Explica por qué apareció, y qué habría pasado si la lista visible se hubiera guardado en un useState en vez de derivarse.

Ver solución

La Webcam apareció porque la lista visible se deriva de products en cada render: getVisibleProducts(products, ...). Entre el render #5 y el #6, products cambió (se agregó la Webcam), así que al derivar de nuevo —con los mismos criterios— la nueva Webcam entró en la lista, en su lugar por precio. No hubo que tocar query, category ni sort, ni recalcular nada a mano: derivar de products hace que cualquier cambio en products se refleje solo en el siguiente render.

Si la lista visible se hubiera guardado en un useState (calculada en algún momento anterior), la Webcam no habría aparecido: la copia guardada seguiría teniendo los productos de antes, y nadie la recalculó al agregar la Webcam. Sería el bug de la desincronización de la lección 4 —la copia stale mostrando un catálogo incompleto—, y solo se arreglaría re-sincronizando la lista a mano cada vez que products cambie. Derivar elimina ese problema por construcción: no hay copia que envejecer.

Resumen y siguiente paso

En este mini-proyecto integraste las siete piezas del módulo construyendo el ProductList de Mercado de punta a punta. Definiste el estado fuente mínimo —query, category, sort, más products— y derivaste todo lo visible con getVisibleProducts: la lista filtrada y ordenada, el conteo, el total, el estado vacío. Lo ejecutaste en Node y viste el módulo entero funcionar: una sesión de cinco acciones donde cada cambio de criterio rederivó la lista (nunca la guardamos); el catálogo cambiando (la Webcam) y la lista actualizándose sola sin desincronizarse; y la key = id conservando el estado local ("Wireless Mouse" marcado) al reordenar. Marcaste el checklist de los seis conceptos. Ahora dominas el estado derivado: sabes qué guardar (el mínimo), qué computar (todo lo demás), por qué no se guarda (se desincroniza), y cómo mantener la identidad estable con la key.

El siguiente módulo, el 6 (efectos y efectos secundarios), agrega la última pieza del modelo de React de esta guía: useEffect, para sincronizar con sistemas externos —cargar products de una API (simulada), suscribirse a algo, hablar con el navegador—. Y arranca justo donde esta lección lo dejó: verás para qué sirve un effect, después de haber visto aquí para qué no (derivar). El estado derivado que aprendiste aquí es, de hecho, la mitad de "You Might Not Need an Effect": muchas cosas que la gente hace con effects se resuelven derivando en el render, como hiciste en todo este módulo.

Recursos