Módulo 8: Project Build Mercados Storefront

La búsqueda controlada y la lista derivada

Descripción

El catálogo ya se carga (lección 3), pero sigue siendo pasivo: muestra todos los productos y no responde a nada. Esta lección le da su primera interacción real —la búsqueda— integrando tres módulos a la vez. La SearchBar, hasta ahora inerte, se vuelve un input controlado: su value sale de un estado query que vive en el App, y cada tecleo dispara onChange, que actualiza ese estado. Y la lista que se muestra ya no es products a secas: es la lista derivada —los productos filtrados por el query y ordenados por precio—, computada en cada render con una función pura, getVisibleProducts, sin guardarse en ningún estado. El resultado: el usuario teclea, el query cambia, el App re-renderiza, y la lista visible se recalcula sola. Búsqueda en vivo, con un estado fuente mínimo.

Conexión con el módulo. Es la tercera capa del capstone, y junta tres módulos. El estado query con useState es el módulo 3. El input controlado (value={query} + onChange) y el callback que sube el texto son el módulo 4. Y la pieza central —derivar, no guardar: la lista visible se computa de products + query en el render, no se almacena en otro estado— es el módulo 5. Se apoya en las capas previas: los products que filtramos son los que cargó el efecto de la lección 3, y el árbol donde vive todo es el andamiaje de la lección 2. Aquí el App gana su primer estado propio (query) y su primera derivación (getVisibleProducts).

Una analogía: el buscador de una biblioteca

Imagina la sala de una biblioteca con un catálogo en fichas y un bibliotecario. Tú no reorganizas los estantes: le dices al bibliotecario qué buscas —"algo con la palabra 'mouse'"— y él, mirando el catálogo completo, te trae en el momento las fichas que coinciden, ordenadas como pediste (por precio, digamos). Si cambias lo que buscas —"ahora con 'e'"—, no guarda tu lista anterior ni la actualiza a mano: vuelve al catálogo completo y arma una lista nueva desde cero. El catálogo (la fuente) no cambia; lo que cambia es tu criterio de búsqueda, y la lista que ves es siempre el resultado fresco de aplicar tu criterio al catálogo.

Eso es exactamente la búsqueda del storefront. El catálogo completo es products (lo que cargó el efecto): la fuente, que no se toca al buscar. Tu criterio es el query: el único estado que cambia cuando tecleas. Y la lista que ves es lo que el bibliotecario te trae: getVisibleProducts(products, query), computada de nuevo en cada render a partir del catálogo y tu criterio. Nunca hay una "lista guardada" que envejezca: cada vez que cambias el criterio, la lista se deriva entera otra vez. Guardar solo el criterio y recalcular la lista es más simple y más seguro que guardar la lista y tener que mantenerla sincronizada —el bibliotecario nunca se equivoca porque nunca guarda copias viejas—.

Ejemplo trabajado: la SearchBar controlada y la lista derivada

Primero, cómo se ve en React real. La SearchBar es un input controlado; el App posee el query y deriva la lista antes de bajarla:

// La derivacion vive fuera del componente: funcion pura, reusable y testeable.
function getVisibleProducts(products, query) {
  const q = query.trim().toLowerCase();
  return products
    .filter((p) => p.name.toLowerCase().includes(q)) // filtra por la busqueda
    .sort((a, b) => a.priceCents - b.priceCents);     // ordena por precio ascendente
}

function SearchBar({ query, onSearch }) {
  return (
    <div className="search-bar">
      <label htmlFor="product-search">Search products</label>
      <input
        id="product-search"
        type="search"
        className="search-input"
        placeholder="Search products..."
        value={query}                              // el input REFLEJA el estado
        onChange={(e) => onSearch(e.target.value)} // y cada tecleo lo ACTUALIZA
      />
    </div>
  );
}

function App({ products }) {
  const [query, setQuery] = useState('');           // ESTADO: el unico dato que cambia al buscar
  const visible = getVisibleProducts(products, query); // DERIVADO: se recalcula en cada render

  return (
    <main className="storefront">
      <SearchBar query={query} onSearch={setQuery} />
      <ProductList products={visible} />            {/* baja la lista YA derivada */}
      <Cart items={[]} />
    </main>
  );
}

Reconoce los tres módulos. El estado query con useState('') (M3): el único dato que cambia al buscar. El input controlado (M4): value={query} hace que el input refleje el estado, y onChange={(e) => onSearch(e.target.value)} hace que cada tecleo lo actualice —el texto sube por el callback onSearch hasta el App, que llama setQuery—. Y la derivación (M5): visible no es un estado, es una variable calculada en el cuerpo del App con getVisibleProducts, que corre en cada render. La lista que baja al ProductList es visible, no products.

Fíjate en lo que no hay: no hay un useState para la lista visible, ni un useEffect que la "sincronice" cuando cambia el query. La lista es una consecuencia del estado (query) y los datos (products), computada en el render. Ese es el corazón del módulo 5, y es lo que mantiene el storefront coherente.

Ahora ejecutemos la derivación —el cerebro de la búsqueda— con una sesión de tecleo. Modelamos el query como el estado que cambia y render() como lo que React haría en cada cambio: recalcular la lista visible:

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

// La lista visible se DERIVA de products + query. Funcion pura, fuera del componente.
function getVisibleProducts(products, query) {
  const q = query.trim().toLowerCase();
  return products
    .filter((p) => p.name.toLowerCase().includes(q)) // filtra por la busqueda
    .sort((a, b) => a.priceCents - b.priceCents);     // ordena por precio ascendente
}

// El unico estado es 'query' (lo que el usuario teclea en la SearchBar controlada).
let query = '';
let renderCount = 0;
function render() {
  renderCount++;
  const visible = getVisibleProducts(PRODUCTS, query); // DERIVADO en cada render
  const names = visible.map((p) => `${p.name} ${formatPrice(p.priceCents)}`).join(' | ');
  console.log(`render #${renderCount}  query="${query}"  ->  ${visible.length} visibles`);
  console.log('   ' + (names || '(sin resultados)'));
}
// onChange de la SearchBar: cambia el estado 'query' y dispara un re-render.
function setQuery(next) { query = next; render(); }

console.log('=== SearchBar controlada + lista derivada ===\n');
render();            // montaje: query=""
setQuery('e');       // el usuario teclea "e"
setQuery('mouse');   // sigue tecleando hasta "mouse"
setQuery('zzz');     // una busqueda sin resultados
setQuery('');        // limpia la busqueda

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

=== SearchBar controlada + lista derivada ===

render #1  query=""  ->  5 visibles
   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"  ->  3 visibles
   Desk Lamp $19.99 | Wireless Mouse $25.99 | Mechanical Keyboard $89.00
render #3  query="mouse"  ->  1 visibles
   Wireless Mouse $25.99
render #4  query="zzz"  ->  0 visibles
   (sin resultados)
render #5  query=""  ->  5 visibles
   Desk Lamp $19.99 | Wireless Mouse $25.99 | USB-C Hub $34.99 | Laptop Stand $45.00 | Mechanical Keyboard $89.00

Lee la sesión render por render; cada línea es la lista derivada de un query distinto.

  • render #1 (query=""): sin filtro, los 5 productos, ordenados de barato a caro (sort por priceCents): Desk Lamp $19.99, Wireless Mouse $25.99, USB-C Hub $34.99, Laptop Stand $45.00, Mechanical Keyboard $89.00. Fíjate en el orden: no es el orden del array original (que empieza por el mouse), sino el derivado por el .sort().
  • render #2 (query="e"): el usuario teclea "e". La lista se deriva a los 3 productos cuyo nombre contiene "e" —Desk Lamp, Wireless Mouse, Mechanical Keyboard—; USB-C Hub y Laptop Stand (sin "e") desaparecen. Sigue ordenada por precio.
  • render #3 (query="mouse"): sigue tecleando hasta "mouse". Solo 1 producto lo contiene: Wireless Mouse. La lista se recortó a uno.
  • render #4 (query="zzz"): una búsqueda que no coincide con nada. 0 visibles → el estado "sin resultados". Aquí es donde ProductList mostrará su <p class="empty-state"> (lección 7); la derivación produjo una lista vacía, y el componente la maneja.
  • render #5 (query=""): el usuario limpia la búsqueda. La lista vuelve a los 5, idéntica al render #1. Nada se "acumuló": cada render derivó la lista entera desde cero.

Lo esencial: en ningún momento guardamos una lista. Cambió un dato —query— y la lista visible se recalculó cinco veces desde PRODUCTS y el query actual. El estado fuente es mínimo (un string); la lista es su consecuencia. Eso es UI = f(estado) en su forma más limpia: la interfaz que ves es una función del estado, recomputada en cada cambio.

Profundización: controlado, derivado, y el estado mínimo

Un input controlado tiene el estado como única fuente de verdad. En un input controlado, el value siempre sale del estado (value={query}), y el input no "recuerda" nada por su cuenta: cada tecla dispara onChange, que actualiza el estado, y el nuevo estado vuelve a fijar el value. El dato vive en React, no en el DOM. Eso te da control total: puedes leer el query en cualquier momento (para derivar la lista), transformarlo (forzar minúsculas, limitar el largo), o resetearlo (un botón "clear" que hace setQuery('')). El input es un reflejo del estado, no un almacén paralelo.

La derivación corre en el render, y por eso nunca se desincroniza. getVisibleProducts(products, query) se llama en el cuerpo del App, así que corre en cada render. Consecuencia clave: si products cambia (llega un producto nuevo del servidor) o query cambia (el usuario teclea), la lista se recalcula sola en el siguiente render, con los datos actuales. No hay una copia guardada que pueda quedar vieja. Compáralo con guardar visible en un useState: tendrías que acordarte de recalcularla en cada handler que toque products o query, y el día que olvides uno, la lista mostrará datos stale. Derivar elimina esa clase entera de bugs por construcción.

El estado fuente debe ser mínimo. La pregunta del módulo 5 —"¿esto es estado o es derivado?"— tiene una respuesta clara aquí: query es estado (lo escribe el usuario, no se deduce de nada); la lista visible, el conteo (visible.length) y "¿está vacío?" son derivados (se calculan de query y products). La regla: guarda el mínimo que no puedas recomputar, y deriva todo lo demás. Un estado fuente corto es un estado fácil de mantener coherente: hay menos cosas que se puedan contradecir.

filter().sort() no muta products. Un detalle que importa: .filter() devuelve un array nuevo, y .sort() ordena ese array nuevo, no products. Por eso derivar no daña la fuente: getVisibleProducts puede correr mil veces y products queda intacto. Si hicieras products.sort(...) directamente (sin el filter que copia antes), mutarías el catálogo original —un bug sutil—. La derivación produce una vista nueva; nunca toca la fuente.

Errores comunes

Guardar la lista visible en un estado. Qué pasa: se crea un const [visible, setVisible] = useState(products) y se llama setVisible(...) en el onChange. Por qué pasa: se siente que "la lista que se muestra" debe vivir en algún estado. Cómo detectarlo: tienes que actualizar visible a mano en cada handler, y si products cambia por otra vía (la carga tardía), la lista no se entera. Cómo corregirlo: visible es derivado —una variable en el cuerpo, getVisibleProducts(products, query)—, no estado. El estado es query; la lista es la consecuencia. Es exactamente lo que el módulo 5 enseña; no lo abandones bajo la presión del proyecto.

Sincronizar la lista con un useEffect. Qué pasa: se pone useEffect(() => { setVisible(getVisibleProducts(products, query)); }, [query, products]) para "mantener la lista al día". Por qué pasa: se confunde "cuando el query cambia, actualiza la lista" con una sincronización con el afuera. Cómo detectarlo: tienes un efecto cuyo único trabajo es copiar un cálculo a otro estado. Cómo corregirlo: filtrar es una derivación en el render (M5), no un efecto (M6). El efecto es para tocar el afuera (la red, en la lección 3); transformar datos que ya tienes se hace en el cuerpo, sin efecto. Este es el caso de libro de "You Might Not Need an Effect".

Hacer el input no controlado (olvidar value u onChange). Qué pasa: se pone value={query} pero no onChange —el input queda "congelado", no puedes teclear— o se pone onChange pero no value —el input teclea pero el query no manda—. Por qué pasa: se olvida que un input controlado necesita las dos mitades. Cómo detectarlo: o el input no acepta texto, o acepta texto pero la lista no se filtra. Cómo corregirlo: un input controlado siempre lleva ambos: value={query} (refleja el estado) y onChange (lo actualiza). Uno sin el otro rompe el lazo.

Ejercicios

Ejercicio 1 — Predice la sesión. Sin correr nada, usando getVisibleProducts y los 5 productos, predice la línea de salida (render #N ... y la lista) para query="a". Ve paso a paso: primero el filtro, luego el orden.

Ver solución

Filtro por "a": nombres que contienen "a" → Mechanical Keyboard (mechanicAl, keyboArd), Laptop Stand (lAptop, stAnd), Desk Lamp (lAmp). Wireless Mouse y USB-C Hub no tienen "a". Quedan tres.

Orden por precio ascendente: Desk Lamp (1999), Laptop Stand (4500), Mechanical Keyboard (8900).

render #N  query="a"  ->  3 visibles
   Desk Lamp $19.99 | Laptop Stand $45.00 | Mechanical Keyboard $89.00

La lista final es el catálogo filtrado por "a" y luego ordenado por precio: la consecuencia de aplicar el criterio a la fuente, derivada.

Ejercicio 2 — Agrega un botón "Clear". Extiende la SearchBar con un botón que vacíe la búsqueda de un clic. Escribe el JSX del botón y explica por qué funciona sin tocar nada más del App.

Ver solución
function SearchBar({ query, onSearch }) {
  return (
    <div className="search-bar">
      <label htmlFor="product-search">Search products</label>
      <input
        id="product-search"
        type="search"
        className="search-input"
        placeholder="Search products..."
        value={query}
        onChange={(e) => onSearch(e.target.value)}
      />
      {query !== '' && (
        <button className="clear-btn" onClick={() => onSearch('')}>Clear</button>
      )}
    </div>
  );
}

El botón llama onSearch(''), que es el mismo callback que sube el texto al App —solo que con string vacío—. Como el query es la única fuente de verdad del input controlado, ponerlo en '' hace dos cosas de un golpe: el input se vacía (porque value={query}) y la lista vuelve a mostrar los 5 productos (porque se deriva de query). No hay que tocar la lista ni el input a mano: cambiar el estado fuente basta. El botón solo aparece si hay algo que limpiar (query !== ''), con &&. Esa es la ventaja de un input controlado con estado derivado: un solo cambio de estado y toda la UI se reacomoda sola.

Ejercicio 3 — ¿Estado o derivado? Mercado quiere agregar, junto a la búsqueda, un texto que diga cuántos productos coinciden ("3 matches") y el precio promedio de los visibles. Para cada uno, di si es estado o derivado, y por qué; luego escribe cómo los calcularías.

Ver solución

Los dos son derivados: se calculan de visible (que ya es derivado de products y query), no son datos que el usuario escriba ni que haya que guardar.

function App({ products }) {
  const [query, setQuery] = useState('');
  const visible = getVisibleProducts(products, query);       // DERIVADO
  const matchCount = visible.length;                          // DERIVADO
  const avgPriceCents = visible.length === 0
    ? 0
    : Math.round(visible.reduce((s, p) => s + p.priceCents, 0) / visible.length); // DERIVADO
  // ... usar matchCount y formatPrice(avgPriceCents) en el JSX ...
}

Ninguno necesita un useState: ambos se computan en el render a partir de visible. Guardarlos en estado sería el error del módulo 5 —una copia que hay que sincronizar y que puede quedar vieja—. El estado fuente sigue siendo solo query; el conteo y el promedio son consecuencias, derivadas en cada render. Nota el guardapolvo del promedio: si visible está vacío, se evita dividir entre cero.

Resumen y siguiente paso

En esta lección le diste al catálogo su búsqueda en vivo, integrando tres módulos. La SearchBar se volvió un input controlado (value={query} + onChange) cuyo texto es estado en el App (query, M3-M4), y la lista visible se deriva de products y query con getVisibleProducts —filtrar por la búsqueda y ordenar por precio, computado en cada render, sin guardarse— (M5). Lo anclaste con el buscador de una biblioteca: guardas el criterio, no la lista; el bibliotecario arma una lista fresca cada vez. Y lo ejecutaste: una sesión de tecleo donde cada query derivó una lista distinta —5, 3, 1, 0 (sin resultados) y de vuelta a 5—, sin acumular nada.

Antes de avanzar deberías poder: hacer un input controlado con value + onChange y un estado query; derivar una lista en el render con una función pura, sin guardarla; distinguir el estado (el criterio) del derivado (la lista, el conteo); y explicar por qué derivar evita la desincronización.

La lección 5 arma la otra mitad del storefront: el carrito. Hasta ahora el Cart mostraba items de utilería; ahora el cart será estado de verdad, levantado en el App (el ancestro común del ProductList que agrega y el Cart que muestra, M7), y su lógica —agregar subiendo cantidad, quitar bajándola, vaciar— vivirá consolidada en el cartReducer, una función pura que conectaremos con useReducer (M3, M7). Ejecutarás una secuencia de acciones y verás el estado del carrito y el total en centavos cuadrar en cada paso, con la prueba de que el reducer no muta. La búsqueda ya vive; toca darle vida al carrito.

Recursos