Module 3: Global Client State With A Store
The cart store in Mercado
Overview
It's time to land the whole module in the storefront. You're going to see Mercado's cart as a complete store: the state (items), the actions (addItem, removeItem, clear) and the selectors (selectCount, selectItems), in the real React code you'd write with Zustand. And you're going to see Mercado's components each connect to its slice: the corner CartBadge subscribed only to the count, the expanded Cart subscribed to the detail (the items with their prices), and each ProductCard's button reading only the addItem action. It's the module's pattern put to work: a store outside the tree, actions that produce new state, fine selectors, and each component reacting only to what it uses. You'll execute it in a shopping session —add, add, remove, empty— and you'll see the CartBadge and the Cart each react to its column, without receiving props from each other, and with the detail formatted ($25.99) as in all of Mercado.
Connection with the module. It's the application, the second-to-last stop before the mini-project. It brings together the previous lessons in the concrete storefront: the store outside React (L2), the state and the actions together (L3), the subscription with a selector (L4), the well-written selectors (L5) and the decision that the cart goes in a store (L6). It's the "real" version of what you've been modeling, with Mercado's real names (useCartStore, CartBadge, Cart). And it sets up the mini-project (L8), which brings this together with the complete classification table. The boundary, underlined once more: the products added to the cart come from the server —in the real storefront they'd arrive with React Query (modules 4-6)—; here we treat them as fixed data to focus on the cart, which is indeed client state.
An analogy: the central board of Mercado's cart
Go back to the office's central board, but now it's the specific board of Mercado's cart, hung on the wall, with two columns that different employees care about.
The first column says, in big letters, how many products are in the cart: a single number, "3". It's the column the front-counter employee (the CartBadge) looks at, the one who has to put the little number on the cart icon at the top right. He only cares about that number. If someone adds a product, the number goes up and he updates it; if the change doesn't touch the count, he doesn't even glance up.
The second column is the detail: the list of products, each with its name and its price, and the total below. It's the column the billing employee (the expanded Cart) looks at, the one who assembles the cart screen with each line. He cares about the whole list: if a product comes in or out, or a price changes, he has to redo his screen.
The two look at the same board —the same source of truth, the same cart— but each at its column. And there's a third character, the runner (the ProductCard), who doesn't even look at the board: he only has a button to note down a new product (addItem). He doesn't need to read anything, only to write. When the runner notes down a mouse, the number of column 1 goes up (and the counter guy sees it) and the list of column 2 grows (and the billing guy redoes it) —each reacts to its column, without the runner telling anyone anything—. That board with its two columns and its note-down button is the cart store: items (the data), selectCount/selectItems (the columns), addItem (the button). Each Mercado component stands in front of the column that applies to it.
Worked example: the cart store and a shopping session
We're going to set up Mercado's cart as a store and run a session. First, the real React code —the store and its consumers— as you'd write it with Zustand:
import { create } from 'zustand';
// Mercado's cart: state + actions, outside the tree. The single source of truth.
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: [] }),
}));
// Reusable selectors (derive from items; return primitives/stable references).
const selectCount = (s) => s.items.length;
const selectItems = (s) => s.items;
// CartBadge: only the count. Re-renders only when the number changes.
function CartBadge() {
const count = useCartStore(selectCount);
return <span className="cart-badge">{count}</span>;
}
// Cart: the detail. Re-renders when the list changes.
function Cart() {
const items = useCartStore(selectItems);
const remove = useCartStore((s) => s.removeItem); // only the action (stable identity)
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)}>Remove</button>
</div>
))}
<strong>Total: {formatPrice(total)}</strong>
</aside>
);
}
// ProductCard: only writes. Reads the addItem action (stable) -> doesn't re-render due to the cart.
function ProductCard({ product }) {
const addItem = useCartStore((s) => s.addItem);
return <button onClick={() => addItem(product)}>Add to cart</button>;
}
Read it with the analogy. The useCartStore is the board; selectCount and selectItems are the two columns; the CartBadge looks at the count one, the Cart at the detail one, and the ProductCard only uses the note-down button (addItem), without reading anything from the cart —that's why it doesn't re-render when the cart changes—. Notice that no component receives the cart through props: they all talk directly to the store, each with its selector.
Now let's execute a shopping session. We subscribe the CartBadge (to selectCount) and the Cart (to selectItems), and we run: add mouse, add keyboard, remove the mouse, empty. At each step we print what each one recomputes:
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)}`;
// Mercado's cart: state + actions + selectors.
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 subscribes ONLY to the count; Cart to the detail (the 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(' | ') || '(empty)';
console.log(` Cart -> ${lines} = ${formatPrice(total)}`);
});
const { addItem, removeItem, clear } = useCartStore.getState();
console.log('=== A shopping session in Mercado (cart in the 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(' Each reacted to its slice; none received props from the other.');
What to expect. When you run the file with Node, the output is exactly this:
=== A shopping session in Mercado (cart in the 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 -> (empty) = $0.00
Re-renders -> CartBadge: 4 | Cart: 4
Each reacted to its slice; none received props from the other.
Read the session step by step, because it's the whole module working in the storefront.
addItem(Mouse): the count goes to 1 (the CartBadge paints (1)) and the list has one item (the Cart paints Wireless Mouse $25.99 with total $25.99). Both react because both columns changed. addItem(Keyboard): the count to 2, the list with two products, total $114.99 (2599 + 8900 = 11499 cents). removeItem(1): the mouse leaves; the count drops to 1, the list keeps the keyboard, total $89.00. clear(): everything to zero; the CartBadge paints (0) and the Cart ends up (empty) with total $0.00.
Notice three things that are the module. First, each component reacts to its slice: the CartBadge to selectCount, the Cart to selectItems —both reading from the same board, each its column—. Second, none receives props from the other: the CartBadge isn't "inside" the Cart nor receives the count through a prop; both talk directly to the store, even though in the React tree they're on totally different branches (the badge above in the Header, the Cart in a side panel). That's the advantage of the store outside the tree: it connects distant components without prop drilling nor a Provider. Third, the detail comes out formatted ($25.99, $114.99) with formatPrice, Mercado's convention since react-fundamentals: prices in cents as integers, formatted only on display.
In this session the count changed in all 4 actions, so the CartBadge re-rendered 4 times; in a session with a free product (as you saw in lessons 1 and 4), it would have re-rendered without moving the Cart's total —the selector makes sure each one reacts only when its column changes—.
Deep dive: how Mercado's cart is assembled
Store items, derive everything else. The store stores one base slice: items, the list of products. The count, the total, whether it's empty, whether there's free shipping —all that are selectors that derive from items (lessons 3 and 5)—. There's no count nor total stored to sync; there's one source and many views of it. That makes it impossible for the badge to show "3" while the cart has 2 products: both come from the same items.
The ProductCard only writes: it reads the action, not the state. The "Add to cart" button doesn't need to know how many products there are; it only needs to be able to add. That's why it reads useCartStore(s => s.addItem) —only the action—. And since the actions have stable identity (they're created once with the store, lesson 3), the ProductCard never re-renders due to cart changes: its selector always returns the same addItem function. A component that only dispatches shouldn't re-render when the state changes, and with the store that comes free. (Remember that in Context this required the refinement of "separating actions from state"; here it's automatic.)
The store connects distant components without plumbing. The CartBadge lives in the Header (top right); the Cart lives in a side panel; the add button lives inside each ProductCard in the grid. Three very separate branches of the tree, and all three touch the same cart without any common ancestor bringing the state down through props nor providing a Context. The store, living outside the tree, is accessible from any branch equally. It's the cleanest solution to the problem of "many distant components share and modify a data that changes often".
The teaser of module 4: where the products come from. In this session, we wrote the products ({ id: 1, name: 'Wireless Mouse', priceCents: 2599 }) by hand. In the real Mercado, that list of products shown in the grid does not live in the store: it comes from the server (an API), and it's a cached copy that has to be loaded, refreshed and handled if it fails. The cart (what the user assembles) is client state and goes in the store; the catalog (what the backend has) is server state and goes in React Query. Modules 4-6 bring that other half; here we keep the boundary clean.
flowchart TD
Store["useCartStore (outside the tree: items + addItem/removeItem/clear)"]
Header --> Badge["CartBadge (selectCount)"]
Grid[ProductGrid] --> Card["ProductCard (s => s.addItem)"]
Panel --> Cart["Cart (selectItems + s => s.removeItem)"]
Badge -. "reads count" .-> Store
Card -. "addItem" .-> Store
Cart -. "reads items / removeItem" .-> Store
Common mistakes
Putting the CartBadge inside the Cart to "share" the count. What happens: since both use the cart, someone nests them or passes the count from the Cart to the CartBadge through a prop —but they live on different branches (the badge in the Header, the Cart in a panel), so it ends up in prop drilling or lifting the state to a distant ancestor—. Why it happens: the habit of sharing through props. How to detect it: the count crosses components that have no natural parent-child relationship. How to fix it: with a store, you don't connect them through the tree; each one reads from the store with its selector, wherever they are. That independence from the position in the tree is exactly what the store provides.
Subscribing the ProductCard to the whole cart. What happens: the "Add to cart" button does const cart = useCartStore() (without a selector) or reads items, and then it re-renders every time the cart changes —hundreds of cards in the grid re-rendering when you add an item—. Why it happens: "I need the store to add". How to detect it: adding a product re-renders the whole product grid. How to fix it: the ProductCard only needs the action: const addItem = useCartStore(s => s.addItem). Since addItem has stable identity, the card doesn't re-render due to cart changes. Read only what you use —and here you only use the button—.
Putting the catalog's products in the cart store. What happens: you add to useCartStore a products slice with the catalog list (which comes from the API), "since I'm managing the cart here". Why it happens: both are "product things". How to detect it: your cart store has a slice that's a copy of a server response. How to fix it: separate the boxes —the cart (what the user assembles) is client state and goes in the store; the catalog (what the backend has) is server state and goes in React Query (M4-M6)—. The cart store stores only items, and the products you add arrive from outside.
Exercises
Exercise 1 — Add an action and its selector. You want Mercado to show "Free shipping" when the cart total reaches $50.00 (5000 cents). (a) Do you need a new action in the store, or is a selector enough? (b) Write what's needed. (c) What selector would the FreeShippingBanner component use?
See solution
(a) A selector is enough. "Free shipping" is a value derived from the cart (does the total reach 5000?); it doesn't change the state, it only reads it. No new action nor new slice is needed.
(b) and (c) The component uses a selector that derives the boolean:
function FreeShippingBanner() {
const hasFreeShipping = useCartStore((s) =>
s.items.reduce((a, it) => a + it.priceCents, 0) >= 5000
);
return hasFreeShipping ? <div>Free shipping</div> : null;
}
The selector returns a boolean (stable identity), so the banner re-renders only when the cart crosses the threshold, not on each item. Nothing to store nor sync: it's derived from items.
Exercise 2 — Trace the session. With the example's cart store, the user: (1) adds USB-C Cable (999), (2) adds Wireless Mouse (2599), (3) adds a Free Sticker (0), (4) removes the USB-C Cable. Write, for each step, what the CartBadge shows (the count) and the total the Cart would compute.
See solution
- (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(the free sticker raises the count but not the total). Here theCart(which shows the list) does re-render —an item came in—, but a component that only showed the total withuseStore(s => selectTotal(s))would not re-render on this step. - (4) removeItem(USB-C Cable) →
CartBadge (2), total$25.99(mouse + sticker remain: 2599 + 0).
The count changed in the 4 steps (the CartBadge re-renders 4 times); the total changed in 1, 2 and 4 (not in 3). The selectors make each component react only to its column.
Exercise 3 — Where does each Mercado data live? The storefront manages several pieces. For each one, say where it lives (cart store / Context / local useState / React Query) and why: (a) the cart's items; (b) the catalog's product list; (c) the light/dark theme; (d) whether the Cart panel is expanded or collapsed; (e) the authenticated user.
See solution
- (a) cart's
items→ cart store. Global client, changes often, many read/write it. The module's case. - (b) Product catalog → React Query (M4-M6). Server state (cached copy of the API). It doesn't go in the store nor Context.
- (c) Theme → Context (M2). Global client, changes little.
- (d) Expanded
Cartpanel → localuseState. It's local UI of the component that opens the panel; the app doesn't share it. (Note: the cart'sitemsare indeed global and persist even if the panel collapses —they live in the store, not the component—.) - (e) User → Context (M2). Global client, changes little.
The key: the cart (what the user assembles, changes often) goes in the store; the catalog (what the backend has) in React Query; the stable global (theme, user) in Context; the local (open panel) in useState. Each box, its tool.
Summary and next step
In this lesson you landed the module in Mercado. You set up the cart as a complete store —items with addItem/removeItem/clear and the selectors selectCount/selectItems— and saw the components each connect to its slice: the CartBadge to the count, the Cart to the detail, the ProductCard only to the addItem action (which, by its stable identity, doesn't make it re-render due to the cart). You executed it in a shopping session —add, add, remove, empty— where each component reacted to its column, none received props from the other despite living on distant branches, and the detail came out formatted ($25.99, $114.99). You anchored it with the cart's central board: the count column (the counter) and the detail one (billing), plus the note-down button (the runner).
Before closing the module you should be able to: set up a global client data that changes often as a store with state, actions and selectors; subscribe each component to the minimal slice it uses (including an action, for those that only write); explain why the store connects distant components without prop drilling; and keep the catalog (server) out of the store.
Lesson 8 is the mini-project: you manage Mercado's cart end to end with a store. You're going to bring together the classification table (cart → store; theme/user → Context; products → React Query; search → URL; menu → local), the complete real React code, and an executed session with three subscribers (CartBadge, CartTotal, Cart) where each reacts only to its column —with the contrast measured against Context—. It's the whole module in one piece, and the closing before moving on to server state.
Resources
- Zustand, "Getting started" — docs.pmnd.rs/zustand/getting-started/introduction. The
createwith state and actions and the use of the Hook with selectors, just as this lesson's cart store is assembled. In English. - TkDodo, "Working with Zustand" — tkdodo.eu/blog/working-with-zustand. Practical patterns: reading stable actions separately, atomic selectors, and why the store connects distant components without plumbing. In English.
- React, "Sharing State Between Components" — react.dev/learn/sharing-state-between-components. The problem of sharing state between components (which in
react-fundamentalswas solved by lifting) and why a store does it without depending on the position in the tree. In English. - TanStack Query, "Overview" — tanstack.com/query/latest/docs/framework/react/overview. Where the catalog's products go (server state), the half this module deliberately leaves out of the store. In English.