Módulo 8: Project Build Mercados Storefront
El carrito levantado en el `App`
Descripción
La búsqueda ya vive (lección 4); toca la otra mitad del storefront: el carrito. Hasta ahora el Cart mostraba items de utilería. En esta lección el cart se vuelve estado de verdad, y con dos decisiones que vienen del módulo 7. Primera: dónde vive. El carrito lo tocan dos componentes hermanos —el ProductList (con sus ProductCard que agregan) y el Cart (que muestra y quita)—, y en React un componente no puede leer el estado de su hermano; así que el carrito sube al ancestro común, el App, y baja por props a los dos. Segunda: cómo cambia. La lógica del carrito no es trivial —agregar un producto que ya está sube su cantidad, quitar la baja o borra la línea, vaciar limpia todo—, así que la consolidamos en una función pura, el cartReducer de la forma (state, action) => newState, y la conectamos al App con useReducer. Y como el reducer es puro, lo ejecutamos sin navegador: una secuencia de acciones, el estado y el total en centavos, literal, y la prueba de que no muta.
Conexión con el módulo. Es la cuarta capa del capstone, y es el módulo 7 (con el 3 debajo) aplicado al storefront. Levantar el estado —el carrito en el App, no en un hermano— es M7 (lecciones 2-3). El cartReducer como función pura con add/remove/clear es M7 (lección 4). Conectarlo con useReducer y despachar acciones con dispatch es M7 (lección 5), montado sobre el useState del módulo 3 (el estado que persiste y dispara re-render). Y toda la inmutabilidad del módulo 3 (no mutar; crear estructuras nuevas) reaparece como la regla dura del reducer. Aquí el reducer todavía es JavaScript puro, aislado; en la lección 6 lo conectamos a los callbacks de los hermanos.
Una analogía: el cajero con su reglamento
Imagina la caja de una tienda y a la persona que la opera. Su trabajo es purísimo: no improvisa. Le llega una operación —"agrega este producto", "quita este", "cancela la compra"— y, mirando el ticket actual y el reglamento de la caja, calcula el ticket nuevo. Si el ticket ya tiene un mouse y llega "agrega otro mouse", no anota una segunda línea: sube la cantidad del mouse a 2. Si llega "quita el mouse" y había dos, baja a uno; si había uno, borra la línea. Si llega "cancela", el ticket queda vacío. El cajero no recuerda nada oculto (el ticket actual siempre se lo dan), no consulta nada de afuera (ni la hora, ni el clima), y no tacha el ticket viejo: produce uno nuevo. Dale el mismo ticket y la misma operación mil veces, y las mil veces dará el mismo resultado.
El cartReducer es ese cajero. El ticket actual es el state (el carrito ahora). La operación es la action ({ type: 'add', product }, { type: 'remove', id }, { type: 'clear' }). El reglamento es el cuerpo de la función: para cada tipo de operación, cómo se calcula el carrito nuevo. Y el ticket nuevo es lo que devuelve. La firma entera es (state, action) => newState: dame el carrito y qué pasó, y te doy el carrito resultante. Lo valioso de este cajero es que es predecible y aislado: como no depende de nada externo ni recuerda nada oculto, puedes probarlo solo —le das un carrito de entrada, una operación, y compruebas el carrito de salida—, sin montar la tienda. Esa es la propiedad que nos deja ejecutar la lógica del carrito en Node.
Ejemplo trabajado: el cartReducer, conectado y corrido
Primero, cómo se ve en React real. El App levanta el carrito con useReducer(cartReducer, []) y baja lo que cada hermano necesita:
function App({ products }) {
const [query, setQuery] = useState('');
const [cart, dispatch] = useReducer(cartReducer, []); // el carrito vive AQUI, en el ancestro comun
const visible = getVisibleProducts(products, query);
return (
<main className="storefront">
<SearchBar query={query} onSearch={setQuery} />
{/* al ProductList le baja la forma de AGREGAR */}
<ProductList
products={visible}
onAddToCart={(product) => dispatch({ type: 'add', product })}
/>
{/* al Cart hermano le bajan los ITEMS y la forma de QUITAR / VACIAR */}
<Cart
items={cart}
onRemoveFromCart={(id) => dispatch({ type: 'remove', id })}
onClear={() => dispatch({ type: 'clear' })}
/>
</main>
);
}
Léelo con la analogía del cajero. El App es la caja: tiene el cart (vía useReducer) y es el único que lo cambia. Los hijos son quienes piden operaciones: el ProductList recibe onAddToCart (su forma de decir "agrega"), el Cart recibe los items (para mostrar) y onRemoveFromCart/onClear (sus formas de decir "quita" / "cancela"). Ningún hijo lleva la lógica de cómo cambia el carrito: solo despachan operaciones (dispatch({ type, ... })), y el reducer —el cajero— decide. Un solo carrito, en el App, leído por los dos hermanos.
Y este es el cartReducer, la función pura con el reglamento —el mismo que escribirás en React, aquí en JavaScript puro para poder ejecutarlo—:
// El cartReducer: una funcion PURA (state, action) => newState.
// Estado del carrito: un array de lineas { id, name, priceCents, qty }.
function cartReducer(state, action) {
switch (action.type) {
case 'add': {
const line = state.find((item) => item.id === action.product.id);
if (line) {
// ya esta en el carrito: sube la cantidad SIN mutar (map -> array nuevo)
return state.map((item) =>
item.id === action.product.id ? { ...item, qty: item.qty + 1 } : item
);
}
// producto nuevo: agrega una linea con qty 1 (array nuevo)
return [...state, { ...action.product, qty: 1 }];
}
case 'remove': {
const line = state.find((item) => item.id === action.id);
if (line && line.qty > 1) {
// baja la cantidad en 1 (sin mutar)
return state.map((item) =>
item.id === action.id ? { ...item, qty: item.qty - 1 } : item
);
}
// qty llega a 0: quita la linea entera (filter -> array nuevo)
return state.filter((item) => item.id !== action.id);
}
case 'clear':
return [];
default:
return state;
}
}
Cada rama produce un carrito nuevo —state.map(...), [...state, ...], state.filter(...) crean arrays nuevos, ninguno toca el state de entrada—. Y el default: return state protege de acciones desconocidas. Ahora corrámoslo con una secuencia que ejercita las tres acciones y las cantidades, con utilidades para el total y para describir el carrito:
function cartTotal(state) { return state.reduce((s, i) => s + i.priceCents * i.qty, 0); }
function formatPrice(cents) { return '$' + (cents / 100).toFixed(2); }
function describe(state) {
const lines = state.map((i) => `${i.name} x${i.qty}`).join(', ');
return `[${lines}] total ${formatPrice(cartTotal(state))}`;
}
const MOUSE = { id: 'p1', name: 'Wireless Mouse', priceCents: 2599 };
const HUB = { id: 'p3', name: 'USB-C Hub', priceCents: 3499 };
const LAMP = { id: 'p5', name: 'Desk Lamp', priceCents: 1999 };
const NAMES = { p1: 'Wireless Mouse', p3: 'USB-C Hub', p5: 'Desk Lamp' };
const actions = [
{ type: 'add', product: MOUSE },
{ type: 'add', product: HUB },
{ type: 'add', product: MOUSE },
{ type: 'remove', id: MOUSE.id },
{ type: 'add', product: LAMP },
{ type: 'clear' },
];
console.log('=== El carrito levantado en App: cartReducer (state, action) => newState ===\n');
let cart = [];
console.log(`${'inicial'.padEnd(24)}${describe(cart)}`);
for (const action of actions) {
cart = cartReducer(cart, action);
const label =
action.type === 'add' ? `add ${action.product.name}` :
action.type === 'remove' ? `remove ${NAMES[action.id]}` : 'clear';
console.log(`${label.padEnd(24)}${describe(cart)}`);
}
console.log('\n=== Pureza: el reducer NO muta el carrito anterior ===');
const before = [{ ...MOUSE, qty: 1 }];
const after = cartReducer(before, { type: 'add', product: MOUSE });
console.log(`antes: ${describe(before)}`);
console.log(`nuevo: ${describe(after)}`);
console.log(`misma referencia? ${before === after}`);
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== El carrito levantado en App: cartReducer (state, action) => newState ===
inicial [] total $0.00
add Wireless Mouse [Wireless Mouse x1] total $25.99
add USB-C Hub [Wireless Mouse x1, USB-C Hub x1] total $60.98
add Wireless Mouse [Wireless Mouse x2, USB-C Hub x1] total $86.97
remove Wireless Mouse [Wireless Mouse x1, USB-C Hub x1] total $60.98
add Desk Lamp [Wireless Mouse x1, USB-C Hub x1, Desk Lamp x1] total $80.97
clear [] total $0.00
=== Pureza: el reducer NO muta el carrito anterior ===
antes: [Wireless Mouse x1] total $25.99
nuevo: [Wireless Mouse x2] total $51.98
misma referencia? false
Lee la secuencia operación por operación, verificando el reglamento del cajero.
- inicial: carrito vacío,
total $0.00. - add Wireless Mouse: el mouse no estaba → línea nueva
Wireless Mouse x1. Total$25.99(2599 centavos, formateados). - add USB-C Hub: tampoco estaba → segunda línea. Total
$60.98(2599 + 3499). - add Wireless Mouse (otra vez): el mouse ya estaba → sube su cantidad a
x2, sin duplicar la línea. Total$86.97(2599×2 + 3499). Aquí ves la regla deaddque no es un simplepush. - remove Wireless Mouse: el mouse tenía
qty 2→ baja a x1 (no se quita, porque queda 1). Total$60.98(2599 + 3499). La rama de "bajar cantidad" deremove. - add Desk Lamp: producto nuevo → tercera línea
x1. Total$80.97(6098 + 1999). - clear: carrito vacío,
total $0.00.
Cada línea de salida es el reducer aplicando su reglamento a una operación: subió cantidades, bajó cantidades, agregó líneas y vació, y el total en centavos cuadró en cada paso. Nunca improvisó; siempre (state, action) => newState.
Y mira la prueba de pureza al final, que es media lección. Tomamos un carrito before con el mouse en qty 1, y le aplicamos add mouse. El resultado after tiene el mouse en qty 2 —correcto—. Pero fíjate en before: sigue en qty 1. El reducer no tocó el carrito que recibió; produjo uno nuevo. Y before === after es false: son arrays distintos, referencias distintas. Eso es exactamente lo que React necesita para "ver" el cambio (M3: React compara referencias). Un reducer que hubiera hecho line.qty++ habría mutado before, dejado la misma referencia, y React no habría re-renderizado. La pureza no es un lujo: es lo que hace que el reducer funcione con React.
Profundización: por qué el carrito se levanta y se maneja con un reducer
Se levanta porque dos hermanos lo comparten. El ProductList (donde están los botones "Add to cart") y el Cart (que muestra el carrito y quita) son componentes hermanos: ambos cuelgan del App, ninguno está dentro del otro. En React, un componente no puede leer el estado de su hermano. Si cada uno guardara su propia copia del carrito, se desincronizarían: agregas desde el ProductList y el Cart sigue mostrando cero. La solución del módulo 7 es levantar: el carrito vive en el ancestro común (App) y baja por props, así hay una sola fuente de verdad que los dos hermanos leen. Se acabó la contradicción.
Se maneja con un reducer porque su lógica tiene reglas. El carrito no es un número: es una lista de líneas con cantidades, y cada acción la transforma con reglas (subir cantidad si ya está, bajar o borrar, vaciar). Si esa lógica viviera repartida en handlers sueltos —uno que busca la línea y decide, otro que copia el array sin mutar—, se volvería un enredo difícil de seguir y de probar. El reducer la consolida en un solo lugar, con la firma (state, action) => newState. Los handlers de los hijos quedan de una línea (solo despachan), y toda la receta vive junta, aislada y verificable. Ahí está el criterio del módulo 7: useReducer cuando el estado tiene varios sub-valores que cambian juntos con reglas —el carrito es el caso de libro—.
La regla dura: un reducer NUNCA muta el estado. Cada rama que cambia algo devuelve una estructura nueva: cambiar un item → state.map(...) (array nuevo con el item copiado); agregar → [...state, nuevo]; quitar → state.filter(...). Prohibido: state.push(...), item.qty++, state[0].qty = 2. Todo eso muta el carrito viejo, deja la misma referencia, y React no ve el cambio (lo comprobaste con before === after → false). La inmutabilidad del módulo 3 no era una recomendación: en un reducer es la ley, y es lo que enlaza la pureza con que React re-renderice.
El reducer es puro; por eso lo pudimos ejecutar. Todo este módulo cumple la promesa de "ejecutar, no citar". El JSX del App con useReducer se muestra (no se puede correr sin React), pero el cartReducer es una función pura —recibe estado y acción, devuelve estado, sin efectos ni memoria oculta—, y una función pura se prueba con una entrada y una salida, sin navegador. Por eso el carrito es la pieza que ejecutamos de verdad: es la lógica de la app en su forma más verificable. Sacar la lógica a un reducer puro no solo la ordena; la vuelve testeable, que es una de las mejores razones para usar reducers, más allá de React.
Errores comunes
Guardar el carrito en un hermano en vez de en el App. Qué pasa: se pone el useReducer (o un useState) del carrito dentro del ProductList (donde están los botones "Add"), y entonces el Cart —su hermano— no puede verlo. Por qué pasa: parece natural que el estado viva "donde se cambia". Cómo detectarlo: agregas productos y el ProductList reacciona, pero el Cart sigue vacío; los dos muestran cosas distintas. Cómo corregirlo: levanta el carrito al ancestro común (App) y bájalo por props. El estado vive donde varios lo comparten, no donde uno lo cambia. Es la lección 2 del módulo 7.
Mutar el estado en el reducer. Qué pasa: escribes state.push(newLine) en add, o line.qty++ en la rama de subir cantidad, y devuelves state. La UI no se actualiza. Por qué pasa: es la forma "natural" de JavaScript, y se olvida que React compara referencias. Cómo detectarlo: el reducer corre (lo ves con un log) pero la pantalla no cambia, porque devolviste la misma referencia que entró. Cómo corregirlo: nunca mutes; devuelve estructuras nuevas con map, filter, spread. Regla mecánica: si en tu reducer aparece push, pop, splice, sort sobre el estado, o un = que asigna a una propiedad del estado, está mal.
Poner la lógica en el handler en vez de en el reducer. Qué pasa: usas useReducer, pero en el onClick calculas el carrito nuevo a mano y despachas { type: 'set', cart: nuevoCart }. El reducer queda como cascarón y perdiste la ventaja. Por qué pasa: la costumbre de calcular el valor nuevo antes de "setearlo", como con useState. Cómo detectarlo: tus acciones cargan el estado ya calculado ({ type: 'set', ... }) en vez de describir qué pasó ({ type: 'add', product }). Cómo corregirlo: la acción describe qué pasó (add con el producto); el reducer calcula el estado nuevo. El handler solo despacha; no cocina. (En la lección 6 los conectamos así.)
Ejercicios
Ejercicio 1 — Predice la secuencia. Con el cartReducer del ejemplo, partiendo de un carrito vacío, aplica estas acciones en orden y escribe el estado y el total tras cada una: (1) add HUB; (2) add HUB; (3) add MOUSE; (4) remove HUB.
Ver solución
- (1) add HUB: el hub no estaba → línea nueva
USB-C Hub x1. Total$34.99(3499). - (2) add HUB: ya estaba → sube a
x2. Total$69.98(3499×2). - (3) add MOUSE: nuevo → segunda línea. Estado
[USB-C Hub x2, Wireless Mouse x1]. Total$95.97(6998 + 2599). - (4) remove HUB: tenía
qty 2→ baja ax1(no se quita). Estado[USB-C Hub x1, Wireless Mouse x1]. Total$60.98(3499 + 2599).
Lo clave: add sobre algo que ya está sube cantidad, y remove sobre algo con qty > 1 baja cantidad (solo quita la línea cuando la cantidad llegaría a 0).
Ejercicio 2 — Agrega una acción setQty. Diseña una acción { type: 'setQty', id, qty } que fije la cantidad de una línea a un número exacto (por ejemplo, el usuario escribe "3" en un input de cantidad). Escribe el case 'setQty' sin mutar, y decide qué pasa si qty es 0.
Ver solución
case 'setQty': {
if (action.qty <= 0) {
// fijar a 0 (o menos) equivale a quitar la linea
return state.filter((item) => item.id !== action.id);
}
return state.map((item) =>
item.id === action.id ? { ...item, qty: action.qty } : item
);
}
- Si
qty <= 0, se quita la línea (filter → array nuevo): una cantidad de 0 no es una línea conqty 0, es no tener la línea. Mantiene el estado limpio. - Si
qty > 0, se fija conmap, copiando el item cambiado ({ ...item, qty: action.qty }) y dejando los demás intactos. Array nuevo, sin mutar.
setQty no necesita saber la cantidad anterior: la fija, no la incrementa. La acción trae todo lo que el reducer necesita (id y qty).
Ejercicio 3 — Encuentra la mutación. Este intento de remove está mal: al quitar, la UI a veces no se actualiza. Encuentra por qué y corrígelo sin mutar.
case 'remove': {
const i = state.findIndex((item) => item.id === action.id);
if (state[i].qty > 1) {
state[i].qty--; // (?)
return state; // (?)
}
state.splice(i, 1); // (?)
return state; // (?)
}
Ver solución
Hay dos mutaciones, y las dos devuelven la misma referencia state, así que React no ve el cambio:
state[i].qty--muta el objeto de la línea existente (que vive dentro destate).state.splice(i, 1)muta el arraystate(le quita un elemento en su lugar).
En ambos casos return state devuelve el mismo array que entró: newState === oldState, y React no re-renderiza. La corrección es devolver estructuras nuevas:
case 'remove': {
const line = state.find((item) => item.id === action.id);
if (line && line.qty > 1) {
return state.map((item) =>
item.id === action.id ? { ...item, qty: item.qty - 1 } : item
);
}
return state.filter((item) => item.id !== action.id);
}
Ahora map (con el item copiado) y filter crean arrays nuevos, sin tocar el state viejo. Referencia nueva → React ve el cambio → re-render. Es el mismo remove del ejemplo trabajado.
Resumen y siguiente paso
En esta lección le diste vida al carrito con las dos decisiones del módulo 7. Dónde vive: el cart levantado en el App (el ancestro común del ProductList que agrega y el Cart que muestra), una sola fuente de verdad que baja por props. Cómo cambia: el cartReducer, una función pura (state, action) => newState con add (sube cantidad o agrega línea), remove (baja cantidad o quita) y clear, conectada al App con useReducer. Lo anclaste con el cajero con su reglamento (ticket actual + operación → ticket nuevo, sin improvisar). Y lo ejecutaste: una secuencia completa donde el estado y el total en centavos cuadraron en cada paso, con la prueba de pureza (before === after → false).
Antes de avanzar deberías poder: levantar un estado compartido al ancestro común; escribir un reducer con switch (action.type), cada rama devolviendo estructuras nuevas y un default: return state; conectarlo con useReducer y bajar los callbacks a los hijos; y explicar por qué la pureza es lo que permite que React vea el cambio.
La lección 6 conecta los cables: hasta ahora la búsqueda (lección 4) y el carrito (esta) viven, pero por separado. Ahora los unimos en un App que maneja ambos —query con useState, cart con useReducer— y conectamos los callbacks que suben (M4): onSearch que cambia el query, onAddToCart/onRemoveFromCart que despachan al reducer. Ejecutarás una sesión completa donde verás que la búsqueda y el carrito son hilos independientes —buscar no toca el carrito, agregar no toca la búsqueda— y que los dos hermanos siempre concuerdan porque leen una sola fuente. El storefront empieza a comportarse como una app de verdad.
Recursos
- React, "Extracting State Logic into a Reducer" — react.dev/learn/extracting-state-logic-into-a-reducer. La página oficial de los reducers: la firma
(state, action) => newState, las acciones, y por qué consolidar la lógica ahí. El núcleo de esta lección. En inglés. - React, "Sharing State Between Components" — react.dev/learn/sharing-state-between-components. Por qué el carrito compartido por dos hermanos sube al ancestro común (
App) y baja por props: levantar el estado. En inglés. - React, "Updating Arrays in State" — react.dev/learn/updating-arrays-in-state. Cómo agregar, quitar y cambiar items de un array sin mutar (
map,filter, spread) —las operaciones delcartReducer—. En inglés. - React, "useReducer" (referencia de la API) — react.dev/reference/react/useReducer. La firma exacta del hook,
dispatch, el estado inicial; la API que escribes en elApp. En inglés.