Módulo 3: Global Client State With A Store
El cart store en Mercado
Descripción
Es hora de aterrizar todo el módulo en el storefront. Vas a ver el carrito de Mercado como un store completo: el estado (items), las acciones (addItem, removeItem, clear) y los selectores (selectCount, selectItems), en el código React real que escribirías con Zustand. Y vas a ver a los componentes de Mercado conectarse cada uno a su rebanada: el CartBadge de la esquina suscrito solo al conteo, el Cart desplegado suscrito al detalle (los items con sus precios), y el botón de cada ProductCard leyendo solo la acción addItem. Es el patrón del módulo puesto a trabajar: un store fuera del árbol, acciones que producen estado nuevo, selectores finos, y cada componente reaccionando solo a lo que usa. Lo ejecutarás en una sesión de compra —agregar, agregar, quitar, vaciar— y verás al CartBadge y al Cart reaccionar cada uno a su columna, sin recibir props uno del otro, y con el detalle formateado ($25.99) como en todo Mercado.
Conexión con el módulo. Es la aplicación, la penúltima parada antes del mini-proyecto. Reúne las lecciones anteriores en el storefront concreto: el store fuera de React (L2), el estado y las acciones juntos (L3), la suscripción con selector (L4), los selectores bien escritos (L5) y la decisión de que el carrito va en un store (L6). Es la versión "de verdad" de lo que has estado modelando, con los nombres reales de Mercado (useCartStore, CartBadge, Cart). Y deja servido el mini-proyecto (L8), que junta esto con la tabla de clasificación completa. La frontera, subrayada una vez más: los productos que se agregan al carrito vienen del servidor —en el storefront real llegarían con React Query (módulos 4-6)—; aquí los tratamos como datos fijos para enfocarnos en el carrito, que sí es estado de cliente.
Una analogía: la pizarra central del carrito de Mercado
Vuelve a la pizarra central de la oficina, pero ahora es la pizarra específica del carrito de Mercado, colgada en la pared, con dos columnas que a distintos empleados les importan.
La primera columna dice, en grande, cuántos productos hay en el carrito: un solo número, "3". Es la columna que mira el empleado del mostrador de entrada (el CartBadge), el que tiene que poner el numerito en el ícono del carrito arriba a la derecha. A él solo le importa ese número. Si alguien agrega un producto, el número sube y él lo actualiza; si el cambio no toca el conteo, ni levanta la vista.
La segunda columna es el detalle: la lista de productos, cada uno con su nombre y su precio, y abajo el total. Es la columna que mira el empleado de facturación (el Cart desplegado), el que arma la pantalla del carrito con cada línea. A él le importa la lista entera: si entra o sale un producto, o cambia un precio, tiene que rehacer su pantalla.
Los dos miran la misma pizarra —la misma fuente de verdad, el mismo carrito— pero cada uno su columna. Y hay un tercer personaje, el repartidor (el ProductCard), que ni mira la pizarra: solo tiene un botón para anotar un producto nuevo (addItem). No necesita leer nada, solo escribir. Cuando el repartidor anota un mouse, el número de la columna 1 sube (y el del mostrador lo ve) y la lista de la columna 2 crece (y el de facturación la rehace) —cada uno reacciona a su columna, sin que el repartidor le diga nada a nadie—. Esa pizarra con sus dos columnas y su botón de anotar es el cart store: items (el dato), selectCount/selectItems (las columnas), addItem (el botón). Cada componente de Mercado se para frente a la columna que le toca.
Ejemplo trabajado: el cart store y una sesión de compra
Vamos a montar el carrito de Mercado como store y a correr una sesión. Primero, el código React real —el store y sus consumidores— tal como lo escribirías con Zustand:
import { create } from 'zustand';
// El carrito de Mercado: estado + acciones, fuera del arbol. La fuente unica de verdad.
const useCartStore = create((set) => ({
items: [],
addItem: (product) => set((s) => ({ items: [...s.items, product] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((it) => it.id !== id) })),
clear: () => set({ items: [] }),
}));
// Selectores reutilizables (derivan de items; devuelven primitivos/referencias estables).
const selectCount = (s) => s.items.length;
const selectItems = (s) => s.items;
// CartBadge: solo el conteo. Re-renderiza solo cuando el numero cambia.
function CartBadge() {
const count = useCartStore(selectCount);
return <span className="cart-badge">{count}</span>;
}
// Cart: el detalle. Re-renderiza cuando la lista cambia.
function Cart() {
const items = useCartStore(selectItems);
const remove = useCartStore((s) => s.removeItem); // solo la accion (identidad estable)
const total = items.reduce((a, it) => a + it.priceCents, 0);
return (
<aside>
{items.map((it) => (
<div key={it.id}>
{it.name} — {formatPrice(it.priceCents)}
<button onClick={() => remove(it.id)}>Quitar</button>
</div>
))}
<strong>Total: {formatPrice(total)}</strong>
</aside>
);
}
// ProductCard: solo escribe. Lee la accion addItem (estable) -> no re-renderiza por el carrito.
function ProductCard({ product }) {
const addItem = useCartStore((s) => s.addItem);
return <button onClick={() => addItem(product)}>Add to cart</button>;
}
Léelo con la analogía. El useCartStore es la pizarra; selectCount y selectItems son las dos columnas; el CartBadge mira la del conteo, el Cart la del detalle, y el ProductCard solo usa el botón de anotar (addItem), sin leer nada del carrito —por eso no re-renderiza cuando el carrito cambia—. Fíjate en que ningún componente recibe el carrito por props: todos hablan directo con el store, cada uno con su selector.
Ahora ejecutemos una sesión de compra. Suscribimos el CartBadge (a selectCount) y el Cart (a selectItems), y corremos: agregar mouse, agregar teclado, quitar el mouse, vaciar. En cada paso imprimimos qué recalcula cada uno:
function createStore(createState) {
let state;
const listeners = new Set();
const getState = () => state;
const setState = (partial) => {
const next = typeof partial === 'function' ? partial(state) : partial;
state = { ...state, ...next };
listeners.forEach((l) => l(state));
};
const subscribe = (listener) => { listeners.add(listener); return () => listeners.delete(listener); };
state = createState(setState, getState);
return { getState, setState, subscribe };
}
function subscribeWithSelector(store, selector, onChange) {
let current = selector(store.getState());
return store.subscribe((state) => {
const next = selector(state);
if (!Object.is(next, current)) { current = next; onChange(next); }
});
}
const formatPrice = (cents) => `$${(cents / 100).toFixed(2)}`;
// El carrito de Mercado: estado + acciones + selectores.
const useCartStore = createStore((set) => ({
items: [],
addItem: (product) => set((s) => ({ items: [...s.items, product] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((it) => it.id !== id) })),
clear: () => set({ items: [] }),
}));
const selectCount = (s) => s.items.length;
const selectItems = (s) => s.items;
// CartBadge se suscribe SOLO al count; Cart al detalle (los items).
let badgeRenders = 0, cartRenders = 0;
subscribeWithSelector(useCartStore, selectCount, (c) => {
badgeRenders++;
console.log(` CartBadge -> (${c})`);
});
subscribeWithSelector(useCartStore, selectItems, (items) => {
cartRenders++;
const total = items.reduce((a, it) => a + it.priceCents, 0);
const lines = items.map((it) => `${it.name} ${formatPrice(it.priceCents)}`).join(' | ') || '(vacio)';
console.log(` Cart -> ${lines} = ${formatPrice(total)}`);
});
const { addItem, removeItem, clear } = useCartStore.getState();
console.log('=== Una sesion de compra en Mercado (carrito en el store) ===\n');
console.log('addItem(Wireless Mouse):');
addItem({ id: 1, name: 'Wireless Mouse', priceCents: 2599 });
console.log('\naddItem(Mechanical Keyboard):');
addItem({ id: 2, name: 'Mechanical Keyboard', priceCents: 8900 });
console.log('\nremoveItem(1 = Mouse):');
removeItem(1);
console.log('\nclear():');
clear();
console.log(`\n Re-renders -> CartBadge: ${badgeRenders} | Cart: ${cartRenders}`);
console.log(' Cada uno reacciono a su rebanada; ninguno recibio props del otro.');
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== Una sesion de compra en Mercado (carrito en el store) ===
addItem(Wireless Mouse):
CartBadge -> (1)
Cart -> Wireless Mouse $25.99 = $25.99
addItem(Mechanical Keyboard):
CartBadge -> (2)
Cart -> Wireless Mouse $25.99 | Mechanical Keyboard $89.00 = $114.99
removeItem(1 = Mouse):
CartBadge -> (1)
Cart -> Mechanical Keyboard $89.00 = $89.00
clear():
CartBadge -> (0)
Cart -> (vacio) = $0.00
Re-renders -> CartBadge: 4 | Cart: 4
Cada uno reacciono a su rebanada; ninguno recibio props del otro.
Lee la sesión paso a paso, porque es el módulo entero funcionando en el storefront.
addItem(Mouse): el conteo pasa a 1 (el CartBadge pinta (1)) y la lista tiene un item (el Cart pinta Wireless Mouse $25.99 con total $25.99). Los dos reaccionan porque las dos columnas cambiaron. addItem(Keyboard): el conteo a 2, la lista con dos productos, total $114.99 (2599 + 8900 = 11499 centavos). removeItem(1): sale el mouse; el conteo baja a 1, la lista queda con el teclado, total $89.00. clear(): todo a cero; el CartBadge pinta (0) y el Cart queda (vacio) con total $0.00.
Fíjate en tres cosas que son el módulo. Primero, cada componente reacciona a su rebanada: el CartBadge a selectCount, el Cart a selectItems —los dos leyendo de la misma pizarra, cada uno su columna—. Segundo, ninguno recibe props del otro: el CartBadge no está "dentro" del Cart ni recibe el conteo por prop; los dos hablan directo con el store, aunque en el árbol de React estén en ramas totalmente distintas (el badge arriba en el Header, el Cart en un panel lateral). Esa es la ventaja del store fuera del árbol: conecta componentes lejanos sin prop drilling ni Provider. Tercero, el detalle sale formateado ($25.99, $114.99) con formatPrice, la convención de Mercado desde react-fundamentals: los precios en centavos como enteros, formateados solo al mostrar.
En esta sesión el conteo cambió en las 4 acciones, así que el CartBadge re-renderizó 4 veces; en una sesión con un producto gratis (como viste en las lecciones 1 y 4), habría re-renderizado sin mover el Cart del total —el selector se encarga de que cada uno reaccione solo cuando su columna cambia—.
Profundización: cómo se arma el carrito de Mercado
Guarda items, deriva todo lo demás. El store guarda una rebanada base: items, la lista de productos. El conteo, el total, si está vacío, si hay envío gratis —todo eso son selectores que derivan de items (lección 3 y 5)—. No hay count ni total guardados que sincronizar; hay una fuente y muchas vistas de ella. Eso hace imposible que el badge muestre "3" mientras el carrito tiene 2 productos: los dos salen del mismo items.
El ProductCard solo escribe: lee la acción, no el estado. El botón "Add to cart" no necesita saber cuántos productos hay; solo necesita poder agregar. Por eso lee useCartStore(s => s.addItem) —solo la acción—. Y como las acciones tienen identidad estable (se crean una vez con el store, lección 3), el ProductCard nunca re-renderiza por cambios del carrito: su selector devuelve siempre la misma función addItem. Un componente que solo despacha no debería re-renderizar cuando el estado cambia, y con el store eso sale gratis. (Recuerda que en Context esto requería el refinamiento de "separar acciones del estado"; aquí es automático.)
El store conecta componentes lejanos sin plomería. El CartBadge vive en el Header (arriba a la derecha); el Cart vive en un panel lateral; el botón de agregar vive dentro de cada ProductCard en la grilla. Tres ramas del árbol muy separadas, y las tres tocan el mismo carrito sin que ningún ancestro común baje el estado por props ni provea un Context. El store, al vivir fuera del árbol, es accesible desde cualquier rama por igual. Es la solución más limpia al problema de "muchos componentes lejanos comparten y modifican un dato que cambia seguido".
El teaser del módulo 4: de dónde salen los productos. En esta sesión, los productos ({ id: 1, name: 'Wireless Mouse', priceCents: 2599 }) los escribimos a mano. En el Mercado real, esa lista de productos que se muestra en la grilla no vive en el store: viene del servidor (una API), y es una copia en caché que hay que cargar, refrescar y manejar si falla. El carrito (lo que el usuario arma) es estado de cliente y va en el store; el catálogo (lo que el backend tiene) es estado del servidor y va en React Query. Los módulos 4-6 traen esa otra mitad; aquí mantenemos la frontera limpia.
flowchart TD
Store["useCartStore (fuera del arbol: items + addItem/removeItem/clear)"]
Header --> Badge["CartBadge (selectCount)"]
Grid[ProductGrid] --> Card["ProductCard (s => s.addItem)"]
Panel --> Cart["Cart (selectItems + s => s.removeItem)"]
Badge -. "lee count" .-> Store
Card -. "addItem" .-> Store
Cart -. "lee items / removeItem" .-> Store
Errores comunes
Poner el CartBadge dentro del Cart para "compartir" el conteo. Qué pasa: como los dos usan el carrito, alguien los anida o pasa el conteo del Cart al CartBadge por prop —pero viven en ramas distintas (el badge en el Header, el Cart en un panel), así que termina en prop drilling o en levantar el estado a un ancestro lejano—. Por qué pasa: la costumbre de compartir por props. Cómo detectarlo: el conteo cruza componentes que no tienen relación de padre-hijo natural. Cómo corregirlo: con un store, no los conectas por el árbol; cada uno lee del store con su selector, estén donde estén. Esa independencia de la posición en el árbol es justo lo que el store aporta.
Suscribir el ProductCard al carrito entero. Qué pasa: el botón "Add to cart" hace const cart = useCartStore() (sin selector) o lee items, y entonces re-renderiza cada vez que el carrito cambia —cientos de tarjetas en la grilla re-renderizando cuando agregas un item—. Por qué pasa: "necesito el store para agregar". Cómo detectarlo: agregar un producto re-renderiza toda la grilla de productos. Cómo corregirlo: el ProductCard solo necesita la acción: const addItem = useCartStore(s => s.addItem). Como addItem tiene identidad estable, la tarjeta no re-renderiza por cambios del carrito. Lee solo lo que usas —y aquí solo usas el botón—.
Meter los productos del catálogo en el cart store. Qué pasa: agregas al useCartStore una rebanada products con la lista del catálogo (que viene de la API), "ya que estoy manejando el carrito aquí". Por qué pasa: los dos son "cosas de productos". Cómo detectarlo: tu cart store tiene una rebanada que es copia de una respuesta del servidor. Cómo corregirlo: separa las cajas —el carrito (lo que el usuario arma) es estado de cliente y va en el store; el catálogo (lo que el backend tiene) es estado del servidor y va en React Query (M4-M6)—. El cart store guarda solo items, y los productos que agregas llegan de fuera.
Ejercicios
Ejercicio 1 — Agrega una acción y su selector. Quieres que Mercado muestre "Envío gratis" cuando el total del carrito llega a $50.00 (5000 centavos). (a) ¿Necesitas una nueva acción en el store, o basta un selector? (b) Escribe lo que haga falta. (c) ¿Qué selector usaría el componente FreeShippingBanner?
Ver solución
(a) Basta un selector. "Envío gratis" es un valor derivado del carrito (¿el total llega a 5000?); no cambia el estado, solo lo lee. No hace falta una acción nueva ni una rebanada nueva.
(b) y (c) El componente usa un selector que deriva el booleano:
function FreeShippingBanner() {
const hasFreeShipping = useCartStore((s) =>
s.items.reduce((a, it) => a + it.priceCents, 0) >= 5000
);
return hasFreeShipping ? <div>Envio gratis</div> : null;
}
El selector devuelve un booleano (identidad estable), así que el banner re-renderiza solo cuando el carrito cruza el umbral, no en cada item. Nada que guardar ni sincronizar: se deriva de items.
Ejercicio 2 — Traza la sesión. Con el cart store del ejemplo, el usuario: (1) agrega USB-C Cable (999), (2) agrega Wireless Mouse (2599), (3) agrega un Free Sticker (0), (4) quita el USB-C Cable. Escribe, para cada paso, qué muestra el CartBadge (el conteo) y el total que calcularía el Cart.
Ver solución
- (1) addItem(USB-C Cable, 999) →
CartBadge (1), total$9.99. - (2) addItem(Wireless Mouse, 2599) →
CartBadge (2), total$35.98(999 + 2599 = 3598). - (3) addItem(Free Sticker, 0) →
CartBadge (3), total$35.98(el sticker gratis sube el conteo pero no el total). Aquí elCart(que muestra la lista) sí re-renderiza —entró un item—, pero un componente que solo mostrara el total conuseStore(s => selectTotal(s))no re-renderizaría en este paso. - (4) removeItem(USB-C Cable) →
CartBadge (2), total$25.99(queda mouse + sticker: 2599 + 0).
El conteo cambió en los 4 pasos (el CartBadge re-renderiza 4 veces); el total cambió en 1, 2 y 4 (no en 3). Los selectores hacen que cada componente reaccione solo a su columna.
Ejercicio 3 — ¿Dónde vive cada dato de Mercado? El storefront maneja varias piezas. Para cada una, di dónde vive (cart store / Context / useState local / React Query) y por qué: (a) los items del carrito; (b) la lista de productos del catálogo; (c) el tema claro/oscuro; (d) si el panel del Cart está desplegado o colapsado; (e) el usuario autenticado.
Ver solución
- (a)
itemsdel carrito → cart store. Global de cliente, cambia seguido, muchos lo leen/escriben. El caso del módulo. - (b) Catálogo de productos → React Query (M4-M6). Estado del servidor (copia en caché de la API). No va en el store ni en Context.
- (c) Tema → Context (M2). Global de cliente, cambia poco.
- (d) Panel del
Cartdesplegado →useStatelocal. Es UI local del componente que abre el panel; no lo comparte la app. (Nota: lositemsdel carrito sí son globales y persisten aunque el panel se colapse —viven en el store, no en el componente—.) - (e) Usuario → Context (M2). Global de cliente, cambia poco.
La clave: el carrito (lo que el usuario arma, cambia seguido) va en el store; el catálogo (lo que el backend tiene) en React Query; lo estable global (tema, usuario) en Context; lo local (panel abierto) en useState. Cada caja, su herramienta.
Resumen y siguiente paso
En esta lección aterrizaste el módulo en Mercado. Montaste el carrito como un store completo —items con addItem/removeItem/clear y los selectores selectCount/selectItems— y viste a los componentes conectarse cada uno a su rebanada: el CartBadge al conteo, el Cart al detalle, el ProductCard solo a la acción addItem (que, por su identidad estable, no lo hace re-renderizar por el carrito). Lo ejecutaste en una sesión de compra —agregar, agregar, quitar, vaciar— donde cada componente reaccionó a su columna, ninguno recibió props del otro pese a vivir en ramas lejanas, y el detalle salió formateado ($25.99, $114.99). Lo anclaste con la pizarra central del carrito: la columna del conteo (el mostrador) y la del detalle (facturación), más el botón de anotar (el repartidor).
Antes de cerrar el módulo deberías poder: montar un dato global de cliente que cambia seguido como un store con estado, acciones y selectores; suscribir cada componente a la rebanada mínima que usa (incluida una acción, para los que solo escriben); explicar por qué el store conecta componentes lejanos sin prop drilling; y mantener el catálogo (servidor) fuera del store.
La lección 8 es el mini-proyecto: manejas el carrito de Mercado de punta a punta con un store. Vas a juntar la tabla de clasificación (cart → store; tema/usuario → Context; productos → React Query; búsqueda → URL; menú → local), el código React real completo, y una sesión ejecutada con tres suscriptores (CartBadge, CartTotal, Cart) donde cada uno reacciona solo a su columna —con el contraste medido contra Context—. Es el módulo entero en una pieza, y el cierre antes de pasar al estado del servidor.
Recursos
- Zustand, "Getting started" — docs.pmnd.rs/zustand/getting-started/introduction. El
createcon estado y acciones y el uso del Hook con selectores, tal como se arma el cart store de esta lección. En inglés. - TkDodo, "Working with Zustand" — tkdodo.eu/blog/working-with-zustand. Patrones prácticos: leer acciones estables por separado, selectores atómicos, y por qué el store conecta componentes lejanos sin plomería. En inglés.
- React, "Sharing State Between Components" — react.dev/learn/sharing-state-between-components. El problema de compartir estado entre componentes (que en
react-fundamentalsse resolvía levantando) y por qué un store lo hace sin depender de la posición en el árbol. En inglés. - TanStack Query, "Overview" — tanstack.com/query/latest/docs/framework/react/overview. A dónde van los productos del catálogo (estado del servidor), la mitad que este módulo deja fuera del store a propósito. En inglés.