Módulo 8: Project Build Mercados Storefront

Conecta los callbacks: `query` y `cart`

Descripción

Tienes las dos mitades del storefront vivas, pero por separado: la búsqueda (lección 4, con su query y su lista derivada) y el carrito (lección 5, con su cartReducer levantado). Esta lección conecta los cables: pone las dos mitades en un mismo App y engancha los callbacks que suben desde los hijos. El App maneja ambos estados —query con useState, cart con useReducer— y baja a cada hijo la forma de cambiar lo que le toca: a la SearchBar, onSearch (que actualiza el query); al ProductList, onAddToCart (que despacha add al reducer); al Cart, onRemoveFromCart y onClear (que despachan remove y clear). El resultado es un storefront que responde de verdad: buscas y la lista se filtra; agregas y el carrito crece; quitas y el carrito baja. Y verás una propiedad clave: la búsqueda y el carrito son hilos independientes —tocar uno no altera el otro—, y los dos hermanos siempre concuerdan porque leen una sola fuente.

Conexión con el módulo. Es la quinta capa del capstone, y cierra el circuito de datos con dos módulos. Los callbacks que suben —el hijo avisa "pasó esto" y el padre decide— son el corazón del módulo 4 (eventos, lección 6). Y que esos callbacks apunten a una sola fuente de verdad en el App (el query y el cart levantados) es el módulo 7 (lecciones 2-3). Es la síntesis de las capas anteriores: el useState del query (lección 4), el useReducer del cart (lección 5), y ahora el cableado que los hace responder al usuario. Con esto el App alcanza su forma casi final; solo falta el pulido (lección 7).

Una analogía: el tablero de control con dos perillas

Imagina el tablero de control de una máquina con dos perillas independientes: una regula la temperatura y otra el volumen. Cada perilla tiene su propio cable que va al mismo panel central, donde vive el estado real de la máquina. Cuando giras la perilla de temperatura, su cable le avisa al panel "sube la temperatura", y el panel actualiza solo la temperatura —el volumen ni se entera—. Cuando giras la de volumen, pasa lo simétrico. Las dos perillas mandan al mismo panel, pero cada una gobierna su valor: girar una nunca mueve la otra. Y como el estado real vive en el panel (no en las perillas), cualquier indicador que lea del panel muestra siempre lo mismo, sin contradicciones.

El storefront es ese tablero. El panel central es el App, dueño de los dos estados: query (la búsqueda) y cart (el carrito). Las perillas son los hijos: la SearchBar gira el query, el ProductList y el Cart giran el cart. Cada uno manda su aviso por un cable —un callback: onSearch, onAddToCart, onRemoveFromCart—, y el App actualiza solo el valor correspondiente. Buscar cambia el query y deja el cart intacto; agregar cambia el cart y deja el query intacto: dos hilos independientes. Y como el cart vive en el panel (el App), el ProductList que agrega y el Cart que muestra leen el mismo carrito: nunca se contradicen. Los cables suben avisos; el panel decide; los indicadores leen del panel.

Ejemplo trabajado: el App completo, con sus dos estados y sus cables

Primero, cómo se ve en React real. Este es el App casi final: maneja query y cart, deriva la lista, y baja los callbacks a cada hijo:

function App({ products }) {
  const [query, setQuery] = useState('');               // ESTADO 1: la busqueda
  const [cart, dispatch] = useReducer(cartReducer, []); // ESTADO 2: el carrito (reducer)
  const visible = getVisibleProducts(products, query);  // DERIVADO de products + query

  return (
    <main className="storefront">
      {/* la SearchBar sube el texto por onSearch -> cambia el query */}
      <SearchBar query={query} onSearch={setQuery} />

      {/* el ProductList sube el producto por onAddToCart -> despacha 'add' */}
      <ProductList
        products={visible}
        onAddToCart={(product) => dispatch({ type: 'add', product })}
      />

      {/* el Cart sube el id por onRemoveFromCart -> despacha 'remove' (y onClear -> 'clear') */}
      <Cart
        items={cart}
        onRemoveFromCart={(id) => dispatch({ type: 'remove', id })}
        onClear={() => dispatch({ type: 'clear' })}
      />
    </main>
  );
}

Y así reciben los hijos esos callbacks y los disparan en los eventos. Fíjate en que cada hijo solo avisa; no calcula el estado nuevo:

function ProductCard({ product, onAddToCart }) {
  return (
    <article className="product-card">
      <h3 className="product-name">{product.name}</h3>
      <p className="product-price">{formatPrice(product.priceCents)}</p>
      {/* la flecha DIFIERE la llamada hasta el clic; sube el product al App */}
      <button
        className="add-btn"
        disabled={!product.inStock}
        onClick={() => onAddToCart(product)}
      >
        Add to cart
      </button>
    </article>
  );
}

function CartItem({ line, onRemoveFromCart }) {
  return (
    <li className="cart-item">
      <span className="cart-item-name">{line.name}</span>
      <span className="cart-item-qty">{'x' + line.qty}</span>
      <button className="remove-btn" onClick={() => onRemoveFromCart(line.id)}>Remove</button>
    </li>
  );
}

Reconoce los dos módulos. Los callbacks que suben (M4): el ProductCard no sabe qué es el carrito; solo llama onAddToCart(product) cuando lo clican —avisa "quieren agregar este producto"—, y el App decide (despacha add). El CartItem llama onRemoveFromCart(line.id) —avisa "quiten este"—. La flecha (onClick={() => ...}) difiere la llamada hasta el clic; sin ella (onClick={onAddToCart(product)}) se llamaría en el render, la trampa del módulo 4. Y la fuente única (M7): el product sube desde una rama del árbol (el ProductList), el App actualiza el cart, y el Cart —en otra rama— lo ve al instante, porque los dos leen el mismo cart.

Ahora ejecutemos la sesión completa, para ver los dos hilos moverse por separado y a los hermanos concordar. Modelamos el App con sus dos estados y sus callbacks, e imprimimos, tras cada acción, lo que ven la lista derivada y el carrito:

// ... cartReducer, cartTotal, formatPrice, getVisibleProducts (como en las lecciones 4-5) ...

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 },
];
const MOUSE = PRODUCTS[0];
const HUB   = PRODUCTS[2];

// El App es dueño de query (useState) y cart (useReducer). Los callbacks bajan a los hijos.
let query = '';
let cart = [];
// Callbacks que SUBEN desde los hijos (modulo 4) y cambian el estado del App:
function onSearch(next)       { query = next; render('SearchBar: onSearch'); }
function onAddToCart(product) { cart = cartReducer(cart, { type: 'add', product }); render('ProductCard: onAddToCart'); }
function onRemoveFromCart(id) { cart = cartReducer(cart, { type: 'remove', id }); render('CartItem: onRemoveFromCart'); }

function render(cause) {
  const visible = getVisibleProducts(PRODUCTS, query);          // DERIVADO de query
  const list = visible.map((p) => p.name).join(', ');
  const cartStr = cart.map((i) => `${i.name} x${i.qty}`).join(', ');
  console.log(`[${cause}]`);
  console.log(`   ProductList (deriva de query="${query}"): ${list || '(sin resultados)'}`);
  console.log(`   Cart (hermano, lee el MISMO cart): [${cartStr}] total ${formatPrice(cartTotal(cart))}`);
}

console.log('=== Conectar los callbacks: query y cart, cada uno su hilo ===\n');
render('montaje');
console.log('');
onSearch('hub');            // el usuario busca "hub": solo la lista cambia
console.log('');
onAddToCart(HUB);           // agrega el Hub: solo el carrito cambia
console.log('');
onSearch('');              // limpia la busqueda: la lista vuelve, el carrito NO se toca
console.log('');
onAddToCart(MOUSE);         // agrega el Mouse
console.log('');
onAddToCart(HUB);           // agrega el Hub otra vez -> sube su cantidad
console.log('');
onRemoveFromCart(HUB.id);   // quita un Hub -> baja su cantidad

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

=== Conectar los callbacks: query y cart, cada uno su hilo ===

[montaje]
   ProductList (deriva de query=""): Desk Lamp, Wireless Mouse, USB-C Hub, Laptop Stand, Mechanical Keyboard
   Cart (hermano, lee el MISMO cart): [] total $0.00

[SearchBar: onSearch]
   ProductList (deriva de query="hub"): USB-C Hub
   Cart (hermano, lee el MISMO cart): [] total $0.00

[ProductCard: onAddToCart]
   ProductList (deriva de query="hub"): USB-C Hub
   Cart (hermano, lee el MISMO cart): [USB-C Hub x1] total $34.99

[SearchBar: onSearch]
   ProductList (deriva de query=""): Desk Lamp, Wireless Mouse, USB-C Hub, Laptop Stand, Mechanical Keyboard
   Cart (hermano, lee el MISMO cart): [USB-C Hub x1] total $34.99

[ProductCard: onAddToCart]
   ProductList (deriva de query=""): Desk Lamp, Wireless Mouse, USB-C Hub, Laptop Stand, Mechanical Keyboard
   Cart (hermano, lee el MISMO cart): [USB-C Hub x1, Wireless Mouse x1] total $60.98

[ProductCard: onAddToCart]
   ProductList (deriva de query=""): Desk Lamp, Wireless Mouse, USB-C Hub, Laptop Stand, Mechanical Keyboard
   Cart (hermano, lee el MISMO cart): [USB-C Hub x2, Wireless Mouse x1] total $95.97

[CartItem: onRemoveFromCart]
   ProductList (deriva de query=""): Desk Lamp, Wireless Mouse, USB-C Hub, Laptop Stand, Mechanical Keyboard
   Cart (hermano, lee el MISMO cart): [USB-C Hub x1, Wireless Mouse x1] total $60.98

Lee la sesión siguiendo los dos hilos, que es lo que esta lección demuestra.

  • montaje: query="" → la lista deriva los 5 productos; el carrito está vacío ($0.00). Punto de partida.
  • onSearch('hub'): el usuario busca "hub". La lista se deriva a solo USB-C Hub. Mira el carrito: sigue vacío. Buscar tocó el hilo del query, no el del cart. Los dos son independientes.
  • onAddToCart(HUB): agrega el hub. El carrito pasa a [USB-C Hub x1], total $34.99. Mira la lista: no cambió (sigue mostrando "USB-C Hub", porque el query sigue en "hub"). Agregar tocó el hilo del cart, no el del query.
  • onSearch(''): limpia la búsqueda. La lista vuelve a los 5. Y el carrito no se tocó: sigue [USB-C Hub x1]. Otra vez: cambiar la búsqueda no altera el carrito.
  • onAddToCart(MOUSE): agrega el mouse. Carrito [USB-C Hub x1, Wireless Mouse x1], total $60.98. La lista intacta.
  • onAddToCart(HUB) (otra vez): el hub ya estaba → sube a x2 (la regla del reducer), total $95.97. No una línea nueva.
  • onRemoveFromCart(HUB.id): el Cart (el otro hermano) pide quitar un hub. El hub tenía qty 2baja a x1, total $60.98.

Detente en las dos verdades de la salida. Primera: los hilos son independientes. En ningún momento buscar cambió el carrito, ni agregar cambió la lista. Son dos perillas del mismo tablero, cada una gobernando su valor. Eso es tener el estado bien separado: query y cart son piezas distintas, y cada callback toca solo la suya. Segunda: los hermanos concuerdan. El ProductList disparó los add y el Cart disparó el remove, pero los dos siempre mostraron el mismo carrito, porque leen el cart que vive en el App —una sola fuente de verdad—. Agregara quien agregara, nunca hubo dos versiones del carrito. Eso es levantar el estado, funcionando.

Profundización: el circuito de datos, cerrado

Los datos bajan, los eventos suben. El storefront ahora exhibe el flujo completo de React en una sola imagen. Los datos bajan por props: query baja a la SearchBar, visible (la lista derivada) al ProductList, cart al Cart. Los eventos suben por callbacks: el texto sube por onSearch, el producto por onAddToCart, el id por onRemoveFromCart. El App, en el medio, es el único que tiene el estado y el único que lo cambia; los hijos leen (props que bajan) y avisan (callbacks que suben), pero no deciden. Ese lazo —bajar datos, subir eventos, decidir arriba— es el circuito de datos de React, y aquí lo ves cerrado de punta a punta.

El hijo avisa; el padre decide. Ninguno de los hijos lleva lógica de estado. El ProductCard no sabe si el carrito ya tiene ese producto ni qué significa "agregar"; solo llama onAddToCart(product). El App recibe ese aviso y decide despachando { type: 'add', product }, y el cartReducer calcula el carrito nuevo. Esa separación —quien sabe qué pasó (el hijo) vs quien sabe qué hacer (el padre + el reducer)— es lo que mantiene a los hijos simples (funciones puras de props + un callback) y la lógica concentrada. Es el mismo principio del "ticket a la cocina" del módulo 7, ahora a escala del árbol.

Separar el estado evita el acoplamiento. Que query y cart sean dos estados distintos (un useState y un useReducer) no es un accidente: es diseño. Si los hubieras metido en un solo objeto de estado ({ query, cart }), cada cambio de búsqueda tocaría la misma referencia que el carrito, y sería fácil que un update pisara al otro por descuido. Manteniéndolos separados, cada callback toca solo su pieza, y los dos hilos no interfieren —lo que viste en la salida—. La regla: agrupa en un estado lo que cambia junto con reglas (las líneas del carrito → un reducer); mantén separado lo que cambia por su cuenta (la búsqueda → su propio useState).

dispatch y setQuery son estables; los inline handlers no. Un detalle práctico: setQuery (de useState) y dispatch (de useReducer) React garantiza que no cambian entre renders, así que son seguros de bajar por props. Los handlers inline que los envuelven ((product) => dispatch({ type: 'add', product })) sí se recrean en cada render, lo cual está bien para esta escala. Si algún día eso importara para performance, hay herramientas (useCallback, memoización) —pero eso es de la guía de performance, no de aquí—. No lo optimices antes de tiempo: primero correcto, después rápido.

Errores comunes

Llamar el callback en vez de pasarlo (la trampa de la flecha). Qué pasa: escribes onClick={onAddToCart(product)} con los paréntesis, y el callback se ejecuta en el render, no en el clic —el carrito cambia solo al pintar, posible bucle—. Por qué pasa: es la trampa clásica del módulo 4 (pasar la función vs llamarla). Cómo detectarlo: productos que se agregan sin que nadie haga clic, o un re-render infinito. Cómo corregirlo: envuelve en una flecha para diferir hasta el evento: onClick={() => onAddToCart(product)}. Se dispara cuando el usuario actúa, no al renderizar.

Meter la lógica del carrito en el handler del hijo. Qué pasa: el ProductCard recibe el cart y el dispatch, y calcula ahí si sube cantidad o agrega, despachando { type: 'set', cart: nuevo }. El hijo se llena de lógica que no le toca y el reducer queda vacío. Por qué pasa: se quiere "resolver todo donde ocurre el clic". Cómo detectarlo: el hijo conoce la forma del carrito y lo transforma. Cómo corregirlo: el hijo solo avisa (onAddToCart(product)); el App decide (despacha add); el reducer calcula. El ProductCard no debería ni recibir el cart: solo el product y el callback.

Fundir query y cart en un solo estado. Qué pasa: se guarda useState({ query: '', cart: [] }) y cada handler tiene que copiar todo el objeto para cambiar una parte, y es fácil pisar el carrito al actualizar la búsqueda (o viceversa). Por qué pasa: parece ordenado tener "el estado del App" en un lugar. Cómo detectarlo: al buscar, el carrito se resetea sin querer, o al agregar, el query se pierde. Cómo corregirlo: son piezas independientes que cambian por su cuenta → dos estados separados (query con useState, cart con useReducer). Cada uno su hilo, sin interferencia.

Ejercicios

Ejercicio 1 — Escribe los cables. El App maneja query (con setQuery) y cart (con dispatch sobre cartReducer). Escribe la prop de callback que baja a cada hijo, despachando/actualizando lo correcto: (a) la SearchBar; (b) el ProductList (para agregar); (c) el Cart (para quitar y para vaciar).

Ver solución
// (a) SearchBar: sube el texto -> actualiza el query
<SearchBar query={query} onSearch={setQuery} />

// (b) ProductList: sube el product -> despacha 'add'
<ProductList
  products={visible}
  onAddToCart={(product) => dispatch({ type: 'add', product })}
/>

// (c) Cart: sube el id -> despacha 'remove'; y un boton -> despacha 'clear'
<Cart
  items={cart}
  onRemoveFromCart={(id) => dispatch({ type: 'remove', id })}
  onClear={() => dispatch({ type: 'clear' })}
/>

Cada callback describe qué pasó y despacha la acción; ninguno calcula el carrito nuevo (eso lo hace el cartReducer). La SearchBar usa setQuery directo (el useState no lleva acciones). Fíjate en qué dato lleva cada uno: onAddToCart lleva el product, onRemoveFromCart lleva el id, onClear no lleva nada —lo que su acción necesita—.

Ejercicio 2 — Predice los dos hilos. Partiendo del montaje (query="", carrito vacío), aplica: (1) onAddToCart(MOUSE); (2) onSearch("lamp"); (3) onAddToCart(LAMP). Para cada paso, di qué muestra la lista derivada y qué el carrito, y confirma que cada acción tocó un solo hilo.

Ver solución
  • (1) onAddToCart(MOUSE): lista → los 5 (query sigue ""); carrito → [Wireless Mouse x1], $25.99. Tocó solo el carrito.
  • (2) onSearch("lamp"): lista → solo Desk Lamp (único con "lamp"); carrito → [Wireless Mouse x1] (sin cambios). Tocó solo la búsqueda.
  • (3) onAddToCart(LAMP): lista → solo Desk Lamp (query sigue "lamp"); carrito → [Wireless Mouse x1, Desk Lamp x1], $25.99 + $19.99 = $45.98. Tocó solo el carrito.

Cada acción movió exactamente un hilo: los onAddToCart cambiaron el cart sin tocar el query, y el onSearch cambió el query sin tocar el cart. Dos perillas independientes en el mismo tablero.

Ejercicio 3 — ¿Por qué concuerdan los hermanos? En la sesión ejecutada, el ProductList dispara los add y el Cart dispara el remove, pero los dos muestran siempre el mismo carrito. Explica por qué, y qué pasaría si cada hermano guardara su propia copia del carrito con un useReducer local.

Ver solución

Concuerdan porque el cart no vive en ninguno de los dos hermanos: vive levantado en el App (su ancestro común), y baja por props a los dos. Hay una sola fuente de verdad. Cuando el ProductList dispara un add, el App actualiza ese cart, y el Cart —que recibe el mismo cart por props— lo ve al instante en el re-render. No hay dos carritos que puedan diferir.

Si cada hermano guardara su propia copia con un useReducer local, se desincronizarían: el ProductList agregaría a su carrito y el Cart seguiría mostrando el suyo, vacío. Agregar dos productos desde el ProductList dejaría al Cart en cero, porque su reducer nunca recibió esos add. La app se contradiría consigo misma —el bug del módulo 7—. Levantar el estado al ancestro común es justo lo que lo evita: un carrito, en un lugar, leído por los dos.

Resumen y siguiente paso

En esta lección cerraste el circuito de datos del storefront. Uniste las dos mitades —búsqueda y carrito— en un App que maneja query (con useState) y cart (con useReducer), y conectaste los callbacks que suben (M4): onSearch que cambia el query, onAddToCart/onRemoveFromCart/onClear que despachan al reducer. Lo anclaste con el tablero de dos perillas (cada perilla gobierna su valor; el panel central decide). Y lo ejecutaste en una sesión completa que probó las dos verdades: la búsqueda y el carrito son hilos independientes (tocar uno no altera el otro), y los dos hermanos siempre concuerdan porque leen el cart levantado en el App.

Antes de avanzar deberías poder: bajar callbacks a los hijos que despachan/actualizan el estado del App; usar la flecha para diferir la llamada hasta el evento; explicar por qué el hijo avisa y el padre decide; y por qué separar query y cart en dos estados evita que se pisen.

La lección 7 le da el pulido que separa un demo de una app terminada. Dos cosas: la key={id} estable que mantiene el estado local pegado al producto correcto cuando la lista se filtra o reordena (M2, M5) —verás, ejecutado, cómo key = id acierta y key = index se rompe—; y los estados vacíos —"No products match your search", "Your cart is empty"— que evitan la pantalla en blanco cuando una búsqueda no encuentra nada o el carrito está sin items. El circuito ya funciona; falta cuidarlo en los bordes.

Recursos