Módulo 7: Lifting State And Composition
Mini-proyecto: el carrito compartido del storefront
Descripción
Llegó el momento de juntar el módulo entero en una sola pieza. El carrito de Mercado toca todo lo que aprendiste aquí: vive levantado en el App (porque lo comparten el ProductList que agrega y el Cart hermano que muestra), su lógica está en el cartReducer manejado con useReducer (porque agregar sube cantidades, quitar las baja, vaciar limpia), baja a los hijos con composición (un Layout con children, para no encadenar props), y la vista está separada en componentes presentational. Este mini-proyecto es la síntesis: el árbol de componentes con su flujo de datos, el código React real de cada pieza, y una sesión de usuario ejecutada en Node —agregar, subir cantidad, quitar, vaciar— con el estado y el total del carrito, literal.
Conexión con el módulo. Es el capstone. Reúne las tres patas: levantar (lecciones 2-3), el reducer con useReducer (lecciones 4-5), y componer + container/presentational (lecciones 6-7). Y cierra la frontera del módulo: aquí el estado compartido se resuelve con lifting + reducer + composición, sin Context ni estado global —que, cuando el dato lo necesita medio árbol, son de frontend-state-and-data—. Con esto terminas el módulo y quedas listo para el capstone de la guía (módulo 8), donde construyes el storefront completo de punta a punta.
El plan: qué vamos a armar
El storefront tiene el carrito como estado central, y tres reglas de diseño que salen del módulo:
- El carrito vive en el
App(el ancestro común deProductListyCart), no en ningún hermano. Una sola fuente de verdad (lección 2). - Su lógica está en el
cartReducer, manejado conuseReduceren elApp. Los handlers solo despachan acciones (add,remove,clear); el reducer decide (lecciones 4-5). - Baja con composición y vista separada. Un
Layoutconchildrenevita el prop drilling (lección 6); elCart/CartViewes presentational (solo muestra sus props), y elAppes el container (tiene el estado) (lección 7).
El App es el dueño del carrito y del dispatch. Los hijos solo avisan despachando; el App, a través del reducer, decide.
El árbol y el flujo de datos
Este es el storefront con el carrito levantado en el App, bajando por props (continuas) y las acciones subiendo por callbacks/dispatch (punteadas):
flowchart TD
App["App (useReducer: cart, dispatch)"]
Layout["Layout (children: no conoce cart)"]
PL[ProductList]
Cart[Cart / CartView]
PC1[ProductCard]
PC2[ProductCard]
CI[CartItem]
App -- "children" --> Layout
App -- "products, onAddToCart" --> PL
App -- "items, onRemoveFromCart, onClear" --> Cart
PL --> PC1
PL --> PC2
Cart --> CI
PC1 -. "dispatch(add, product)" .-> App
CI -. "dispatch(remove, id)" .-> App
Léelo en las dos direcciones. Hacia abajo (continuas): el App baja al ProductList la forma de agregar (onAddToCart), y al Cart los items para mostrar más las formas de quitar y vaciar (onRemoveFromCart, onClear). El Layout recibe children —no conoce el carrito—, envolviendo la vista sin cargar props ajenas (composición, lección 6). Hacia arriba (punteadas): el ProductCard y el CartItem despachan acciones que suben al reducer del App. Todo el estado vive en un solo lugar (el App, container); los demás lo muestran o piden cambios (presentational). Ese es el módulo entero, dibujado.
El código React real
Este es el storefront tal como lo escribirías en React. Léelo pieza por pieza, notando dónde aterriza cada lección.
App — container: dueño del carrito levantado y del reducer.
function App() {
// El carrito vive AQUI (ancestro comun), manejado por el cartReducer.
const [cart, dispatch] = useReducer(cartReducer, []);
return (
<Layout>
{/* al ProductList le baja la forma de AGREGAR */}
<ProductList
products={PRODUCTS}
onAddToCart={(product) => dispatch({ type: 'add', product })}
/>
{/* al Cart hermano le bajan los ITEMS y las formas de quitar/vaciar */}
<Cart
items={cart}
onRemoveFromCart={(id) => dispatch({ type: 'remove', id })}
onClear={() => dispatch({ type: 'clear' })}
/>
</Layout>
);
}
El App tiene el estado (useReducer) y baja a cada hijo lo justo (lección 2). Los handlers son de una línea: despachan un ticket, no calculan el carrito (lección 5). Y el App no perfora props por el Layout: le pasa el contenido como children (lección 6).
Layout — envoltorio genérico con children (composición).
function Layout({ children }) {
// No conoce el carrito: solo pinta lo que le metan en su hueco.
return (
<div className="app-shell">
<header><h1>Mercado</h1></header>
<main>{children}</main>
</div>
);
}
El Layout recibe children y lo coloca; no menciona cart ni dispatch. Es el marco de cuadro: sirve para envolver cualquier cosa, y el carrito no cruza por él (lección 6).
ProductList y ProductCard — despachan add.
function ProductList({ products, onAddToCart }) {
return (
<section className="product-list">
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={onAddToCart} />
))}
</section>
);
}
function ProductCard({ product, onAddToCart }) {
return (
<article className="product-card">
<h3>{product.name}</h3>
<p>{formatPrice(product.priceCents)}</p>
{/* sube la accion: la flecha difiere la llamada hasta el click (modulo 4) */}
<button onClick={() => onAddToCart(product)}>Add to cart</button>
</article>
);
}
ProductList reenvía onAddToCart a cada ProductCard (reenvío legítimo a hijos que él renderiza, lección 6). El ProductCard es presentational: muestra el producto y avisa por el callback; no toca el carrito (lección 7).
Cart y CartItem — presentational: muestran y despachan remove/clear.
function Cart({ items, onRemoveFromCart, onClear }) {
const total = items.reduce((sum, i) => sum + i.priceCents * i.qty, 0);
return (
<section className="cart">
<h2>Cart ({items.length})</h2>
<ul>
{items.map((item) => (
<CartItem key={item.id} item={item} onRemove={onRemoveFromCart} />
))}
</ul>
<p>TOTAL: {formatPrice(total)}</p>
<button onClick={onClear}>Clear cart</button>
</section>
);
}
function CartItem({ item, onRemove }) {
return (
<li>
{item.name} x{item.qty} — {formatPrice(item.priceCents * item.qty)}
<button onClick={() => onRemove(item.id)}>Remove</button>
</li>
);
}
El Cart es una función pura de sus props: recibe items y los hilos (onRemoveFromCart, onClear), y solo muestra —el total lo deriva de los items, no lo guarda (módulo 5)—. No tiene useReducer ni sabe de dónde vienen los items (lección 7). El CartItem avisa por onRemove(item.id); el App decide despachar remove.
Ejemplo trabajado: una sesión de usuario, ejecutada
Vamos a correr el storefront simulando una sesión real: el usuario agrega el mouse, agrega el keyboard, agrega el mouse otra vez (sube su cantidad a 2), quita el keyboard, agrega el hub, y por fin vacía el carrito. Modelamos el App con su carrito levantado y su dispatch, y en cada acción "renderizamos" describiendo lo que ven los dos hermanos —el ProductList (siempre los 3 productos) y el Cart (los items y el total)—.
// ---- el reducer PURO: (state, action) => newState (leccion 4) ----
function cartReducer(state, action) {
switch (action.type) {
case 'add': {
const line = state.find((item) => item.id === action.product.id);
if (line) {
return state.map((item) =>
item.id === action.product.id ? { ...item, qty: item.qty + 1 } : item);
}
return [...state, { ...action.product, qty: 1 }];
}
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);
}
case 'clear': return [];
default: return state;
}
}
function cartTotal(state) { return state.reduce((s, i) => s + i.priceCents * i.qty, 0); }
function formatPrice(cents) { return '$' + (cents / 100).toFixed(2); }
// ---- App: dueño del carrito levantado + un dispatch que baja por props ----
const PRODUCTS = [
{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599 },
{ id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
{ id: 'p3', name: 'USB-C Hub', priceCents: 3499 },
];
let cart = [];
function dispatch(action) { cart = cartReducer(cart, action); render(action); }
// ---- "render": describe la UI para el estado actual (los dos hermanos) ----
function render(lastAction) {
const count = cart.reduce((s, i) => s + i.qty, 0);
const lines = cart.map((i) => `${i.name} x${i.qty}`).join(', ');
const tag = lastAction ? lastAction.type.toUpperCase().padEnd(7) : 'INICIAL';
console.log(`${tag} | ProductList: 3 productos | Cart(${count}): [${lines}] total ${formatPrice(cartTotal(cart))}`);
}
// ---- una sesion del usuario ----
console.log('=== Storefront: carrito levantado + cartReducer + composicion ===\n');
render(null);
dispatch({ type: 'add', product: PRODUCTS[0] }); // ProductList: agrega Mouse
dispatch({ type: 'add', product: PRODUCTS[1] }); // ProductList: agrega Keyboard
dispatch({ type: 'add', product: PRODUCTS[0] }); // ProductList: agrega Mouse otra vez (qty 2)
dispatch({ type: 'remove', id: PRODUCTS[1].id }); // Cart: quita el Keyboard
dispatch({ type: 'add', product: PRODUCTS[2] }); // ProductList: agrega Hub
dispatch({ type: 'clear' }); // Cart: vacia todo
console.log(`\nCarrito final: [${cart.map((i) => i.name + ' x' + i.qty).join(', ')}] (vacio)`);
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== Storefront: carrito levantado + cartReducer + composicion ===
INICIAL | ProductList: 3 productos | Cart(0): [] total $0.00
ADD | ProductList: 3 productos | Cart(1): [Wireless Mouse x1] total $25.99
ADD | ProductList: 3 productos | Cart(2): [Wireless Mouse x1, Mechanical Keyboard x1] total $114.99
ADD | ProductList: 3 productos | Cart(3): [Wireless Mouse x2, Mechanical Keyboard x1] total $140.98
REMOVE | ProductList: 3 productos | Cart(2): [Wireless Mouse x2] total $51.98
ADD | ProductList: 3 productos | Cart(3): [Wireless Mouse x2, USB-C Hub x1] total $86.97
CLEAR | ProductList: 3 productos | Cart(0): [] total $0.00
Carrito final: [] (vacio)
Lee la sesión de principio a fin, porque en ella está el módulo entero funcionando junto.
El estado inicial muestra el ProductList con sus 3 productos y el Cart vacío (Cart(0), total $0.00). Los dos hermanos parten de la misma verdad, porque leen el mismo cart del App.
Luego el usuario agrega el mouse desde el ProductList: se despacha { type: 'add', product: MOUSE }, el reducer del App calcula el carrito nuevo, y el Cart —el hermano— lo muestra al instante: Cart(1): [Wireless Mouse x1] total $25.99. Agrega el keyboard: Cart(2), total $114.99 (2599 + 8900). Agrega el mouse otra vez: aquí se ve la lógica del reducer —en vez de duplicar la línea, sube la cantidad a x2—, y el conteo pasa a Cart(3) (tres unidades, dos líneas), total $140.98 (2599×2 + 8900).
El usuario quita el keyboard desde el Cart: el otro hermano dispara { type: 'remove', id }, el reducer quita esa línea (tenía qty 1), y queda Cart(2): [Wireless Mouse x2], total $51.98. Agrega el hub: tercera línea, Cart(3), total $86.97 (5198 + 3499). Y por fin vacía: clear deja el carrito en Cart(0), total $0.00. El Carrito final confirma que quedó vacío.
Detente en lo esencial, que es el módulo entero: agregara quien agregara y quitara quien quitara, hubo un solo carrito en el App, y los dos hermanos siempre mostraron lo mismo. El ProductList disparó add, el Cart disparó remove y clear, pero ninguno guardó estado propio: todos despacharon acciones al reducer del container, que decidió, y la vista siguió. Levantar (un solo carrito), el reducer (la lógica de add/remove/clear con cantidades), y la separación container/presentational (el App decide, el Cart muestra), en una sola sesión ejecutada.
Errores comunes
Guardar el carrito en un hermano en vez de en el App. Qué pasa: pones el useReducer del carrito dentro del ProductList o del Cart, y el otro hermano no lo ve; se desincronizan (lección 1). Por qué pasa: parece natural que el estado viva "donde se cambia" o "donde se muestra". Cómo detectarlo: agregas y el Cart no reacciona, o quitas y el conteo no baja en el otro lado. Cómo corregirlo: el carrito vive en el ancestro común (el App), y baja por props a los dos hermanos. Es la regla central del módulo.
Poner la lógica del carrito en los handlers en vez del reducer. Qué pasa: el App usa useReducer pero los handlers calculan el carrito a mano y despachan { type: 'set', cart }; el reducer queda como cascarón. Por qué pasa: la costumbre de calcular el valor nuevo antes de setearlo. Cómo detectarlo: tus acciones cargan estado ya calculado en vez de describir qué pasó. Cómo corregirlo: las acciones describen la interacción (add, remove, clear) y el reducer calcula; los handlers solo despachan (lección 5).
Perforar props por el Layout (o darle estado al Cart). Qué pasa: pasas cart/dispatch por el Layout que no los usa (prop drilling), o el Cart copia los items a su propio useState y se desincroniza. Por qué pasa: bajar todo por props "por costumbre", o guardar en estado lo que llega. Cómo detectarlo: el Layout recibe props que no toca, o el Cart no refleja cambios del carrito. Cómo corregirlo: al Layout pásale children (composición, lección 6); el Cart muestra sus props directo, sin copiarlas a estado (presentational, lección 7). El total se deriva de los items, no se guarda (módulo 5).
Ejercicios
Ejercicio 1 — Traza una sesión nueva. Con el modelo del ejemplo trabajado (mismos PRODUCTS), el usuario: agrega el hub, agrega el hub otra vez, agrega el mouse, y quita el hub una vez. Escribe la línea de render final: el conteo del carrito (Cart(N)), las líneas y el total.
Ver solución
Paso a paso:
- add HUB: nuevo →
[USB-C Hub x1].Cart(1), total$34.99(3499). - add HUB: ya está → sube a
x2.Cart(2), total$69.98(3499×2). - add MOUSE: nuevo →
[USB-C Hub x2, Wireless Mouse x1].Cart(3), total$95.97(6998 + 2599). - remove HUB: tenía
qty 2→ baja ax1(no se quita).[USB-C Hub x1, Wireless Mouse x1].Cart(2), total$60.98(3499 + 2599).
La línea final:
REMOVE | ProductList: 3 productos | Cart(2): [USB-C Hub x1, Wireless Mouse x1] total $60.98
Lo clave: add sobre algo existente sube cantidad, y remove sobre algo con qty > 1 baja cantidad (no borra la línea). El conteo Cart(N) suma cantidades, no líneas.
Ejercicio 2 — Agrega "clear" y un badge. El header debe mostrar un CartBadge con cuántos items hay (suma de cantidades). (a) ¿Dónde vive ese dato y cómo llega al CartBadge? (b) Escribe el CartBadge como componente presentational. (c) ¿Por qué no necesita su propio useReducer?
Ver solución
(a) El conteo se deriva del carrito, que ya vive levantado en el App. El App calcula (o pasa el cart) y el CartBadge recibe el número por props. Si el CartBadge está en el Header dentro del Layout, el App puede armarlo y pasarlo como parte del children/un slot, para no perforar props (composición).
(b) Presentational, función pura de props:
function CartBadge({ count }) {
return <span className="badge">{count}</span>;
}
// el App lo arma con el dato derivado:
// const count = cart.reduce((s, i) => s + i.qty, 0);
// <CartBadge count={count} />
(c) No necesita useReducer (ni ningún estado) porque no es dueño de ningún dato: solo muestra un número que le llega por props. El carrito ya vive en el App (una sola fuente de verdad); duplicarlo en el CartBadge lo desincronizaría (lección 3). El CartBadge es un títere: mismas props → misma pinta.
Ejercicio 3 — ¿Qué lección resuelve qué? El storefront de este proyecto toca todo el módulo. Para cada pieza, di de qué lección es el concepto de fondo: (a) el carrito viviendo en el App y no en un hermano; (b) dispatch({ type: 'add', product }) en el handler; (c) el Layout recibiendo children sin conocer el carrito; (d) el Cart siendo una función pura de sus props.
Ver solución
- (a) El carrito en el
App— levantar el estado (lecciones 2-3): el estado compartido vive en el ancestro común, no en un hermano. - (b)
dispatch({ type: 'add', product })—useReducery el reducer (lecciones 4-5): el handler despacha una acción; elcartReducer(función pura) decide el estado nuevo. - (c) El
Layoutconchildren— composición sobre prop drilling (lección 6): el carrito no cruza elLayout; se arma donde vive y viaja comochildren. - (d) El
Cartcomo función pura de props — container vs presentational (lección 7): elCartsolo muestra sus props; el estado y la lógica viven en elApp(container).
El proyecto integra las tres patas del módulo —levantar, reducer, componer— en el storefront. Y ninguna necesitó Context ni estado global: para este caso, lifting + composición bastan (la frontera con frontend-state-and-data).
Resumen y siguiente paso
En este mini-proyecto armaste el carrito compartido del storefront de punta a punta y viste el módulo entero funcionar junto. El carrito vive levantado en el App (una sola fuente de verdad para los dos hermanos), su lógica está en el cartReducer manejado con useReducer (los handlers solo despachan add/remove/clear; el reducer decide), baja con composición (un Layout con children, sin prop drilling), y la vista está separada en presentational (el Cart, función pura de sus props; el App, container con el estado). Lo mediste en una sesión de usuario completa —agregar, subir cantidad, quitar, vaciar— con el estado y el total del carrito ($140.98, $86.97, y de vuelta a $0.00), y en ningún momento hubo dos versiones del carrito: agregara o quitara quien fuera, los dos hermanos mostraron lo mismo, porque leían el mismo cart del container.
Antes de cerrar el módulo deberías poder: ubicar el estado compartido en el ancestro común; manejarlo con un reducer y despachar acciones desde los handlers; componer con children para no encadenar props; separar container (estado) de presentational (vista); y distinguir qué resuelve este módulo (lifting + reducer + composición) de lo que es de frontend-state-and-data (Context, estado global).
Y hacia dónde sigue la guía. Con este módulo tienes las cuatro grandes piezas de React del lado del cliente: componentes y JSX (módulos 1-2), estado y eventos (módulos 3-4), derivar y efectos (módulos 5-6), y compartir estado y componer (módulo 7). El módulo 8 es el capstone: construyes el storefront de Mercado completo —ProductList con SearchBar (input controlado), filtrado/orden derivado, Cart con el cartReducer (estado levantado en el App), y un efecto que carga los productos (simulado)— integrando todo, con el árbol de componentes, el código React real y la lógica ejecutada en Node. Y después, el ecosistema Fullstack te lleva a producción: nextjs-app-router (SSR/routing), frontend-state-and-data (Context, estado global, datos del servidor), ui-systems-and-design-implementation (estilos), y fullstack-performance-and-deployment (performance y deploy).
Recursos
- React, "Sharing State Between Components" — react.dev/learn/sharing-state-between-components. El repaso completo de levantar el estado, aplicado al carrito compartido de este proyecto. En inglés.
- React, "Extracting State Logic into a Reducer" — react.dev/learn/extracting-state-logic-into-a-reducer. El
cartReduceryuseReducerque manejan el carrito delApp. En inglés. - React, "Passing Props to a Component: Passing JSX as children" — react.dev/learn/passing-props-to-a-component#passing-jsx-as-children. El
Layoutconchildrenque evita el prop drilling del carrito. En inglés. - React, "Thinking in React" — react.dev/learn/thinking-in-react. El ensayo canónico que integra descomponer, estado, dónde vive y flujo de datos; el marco completo que este proyecto pone en práctica y que el módulo 8 llevará al storefront entero. En inglés.