Module 3: Global Client State With A Store
Mini-project: manage the cart in a store
Overview
The moment has come to bring the whole module together in a single piece. You're going to manage Mercado's cart end to end with a store, with the three parts you already know how to build: the classification table that places each Mercado data in its tool, the real React code of the store and its consumers, and a session executed in Node with three subscribers —the CartBadge (count), the CartTotal (total) and the Cart (detail)— where each one reacts only to its slice, with the contrast measured against Context. This mini-project is the synthesis: it brings together the store outside React, the actions that produce new state, the fine selectors, the decision that the cart goes in a store, and the boundary (the products, server's, stay out). It's the storefront with its state that changes often, finally in its place.
Connection with the module. It's the capstone. It brings together the seven lessons: the cart-that-changes-often problem (L1), the store outside React (L2), the state and the actions together (L3), the subscription with a selector (L4), the well-written selectors (L5), the store/Context/lift decision (L6) and the application to Mercado (L7). And it closes the module's boundary: the global client state that changes often (the cart) is resolved with a store; the one that changes little (theme, user) stays in Context (M2); the server's (products) is pointed to React Query (M4-M6), and the URL's (search) to searchParams (M7). With this you finish the store module and are ready for the most important half of the guide: server state.
The plan: what we're going to build
The cart has three design rules that come out of the module:
- Classify before choosing. Each piece of Mercado's state goes in its tool (modules 1 and 6). Only the global client that changes often and is read/written by many goes in a store: the cart.
- State and actions together, outside the tree, with selectors. The
itemsand its actions (addItem/removeItem/clear) in a single store outside React (L2, L3); each component subscribed to its slice with a fine selector (L4, L5), so a change touches only whoever uses it. - What isn't the cart's, out of the store. The theme and the user don't go here (they change little → Context, M2); the products don't either (they're server's → React Query, M4-M6); the search doesn't either (it goes in the URL → M7); an open menu doesn't either (it's local →
useState).
The state classification table
Before a line of code, the decision made explicit. This is the picture of Mercado's state spread across its tools, with the cart already in its place:
State piece What it is Tool
───────────────────── ──────────────────────────────── ─────────────────────────────
cart (items) global client, changes OFTEN store + selectors (this module)
theme / user global client, changes little Context (M2)
products server (cache) React Query (M4-M6)
search / filters URL searchParams (M7)
dropdown / open menu local to a component useState (lift)
Read it as the summary of where you are in the guide: the cart finally has its tool (this module); the theme and the user already have theirs (Context, M2); and two boxes remain to resolve —the products (server, M4-M6) and the search (URL, M7)—. This project builds the first row and leaves the others pointed to their module.
The tree and the data flow
This is the storefront with the cart in a store outside the tree, and three consumers each subscribed to its slice. The products appear aside, pointing to their tool:
flowchart TD
Store["useCartStore (outside the tree: items + addItem/removeItem/clear)"]
App[App]
Header --> Badge["CartBadge (selectCount)"]
App --> Header
App --> Grid[ProductGrid]
Grid --> Card["ProductCard (s => s.addItem)"]
App --> Panel[CartPanel]
Panel --> Total["CartTotal (selectTotal)"]
Panel --> Cart["Cart (selectItems)"]
Server["products -> React Query (M4-M6, NOT the store)"]
Badge -. "count" .-> Store
Card -. "addItem" .-> Store
Total -. "total" .-> Store
Cart -. "items" .-> Store
Read it with what you've learned. The useCartStore is the central board, outside the tree, and the components connect with dotted lines —each its slice—: the CartBadge reads the count (in the Header), the CartTotal the total and the Cart the detail (in the CartPanel), and the ProductCard (in the grid) only uses the addItem action. Four components on three distant branches, all talking directly to the same store, without prop drilling. And the products are outside, with a note: their tool is React Query (M4-M6), not the store.
The real React code
This is Mercado's cart as you'd write it with Zustand. Read it piece by piece, noting where each lesson lands.
The store — state and actions together, outside the tree (L2, L3).
import { create } from 'zustand';
const useCartStore = create((set) => ({
items: [], // state (the only base slice)
addItem: (product) => set((s) => ({ items: [...s.items, product] })), // doesn't mutate: creates new
removeItem: (id) => set((s) => ({ items: s.items.filter((it) => it.id !== id) })),
clear: () => set({ items: [] }),
}));
// Reusable selectors: derive from items, return primitives/stable references (L5).
const selectCount = (s) => s.items.length;
const selectTotal = (s) => s.items.reduce((a, it) => a + it.priceCents, 0);
const selectItems = (s) => s.items;
The store stores only items (L3: store the base data, derive the rest); the actions produce new state without mutating (L3); and the selectors derive what each component needs, returning primitives where possible (selectCount, selectTotal) so as not to re-render too much (L5).
The consumers — each to its slice (L4).
function CartBadge() { // in the Header
const count = useCartStore(selectCount); // only the count -> re-render if the number changes
return <span className="cart-badge">{count}</span>;
}
function CartTotal() { // in the CartPanel
const total = useCartStore(selectTotal); // only the total -> re-render if the total changes
return <strong>Total: {formatPrice(total)}</strong>;
}
function Cart() { // in the CartPanel
const items = useCartStore(selectItems); // the list -> re-render if the list changes
const remove = useCartStore((s) => s.removeItem); // only the action (stable identity)
return (
<ul>
{items.map((it) => (
<li key={it.id}>
{it.name} — {formatPrice(it.priceCents)}
<button onClick={() => remove(it.id)}>Remove</button>
</li>
))}
</ul>
);
}
function ProductCard({ product }) { // in the product grid
const addItem = useCartStore((s) => s.addItem); // only the action -> doesn't re-render due to the cart
return <button onClick={() => addItem(product)}>Add to cart</button>;
}
None receives the cart through props: the four read from the store with their selector. The CartBadge the count, the CartTotal the total, the Cart the detail, and the ProductCard only the addItem action —which, by its stable identity, doesn't make it re-render when the cart changes (L3)—. The formatPrice(2599) → "$25.99" is the usual one.
The App — doesn't need a Provider for the cart.
function App() {
// The cart store lives OUTSIDE the tree: App does NOT wrap anything for the cart.
// (The theme and the user do go in their Context Providers, from module 2.)
return (
<ThemeProvider>
<UserProvider>
<Header /> {/* contains CartBadge */}
<ProductGrid /> {/* contains ProductCards */}
<CartPanel /> {/* contains CartTotal and Cart */}
</UserProvider>
</ThemeProvider>
);
}
Notice what's not there: no <CartProvider>. The cart doesn't need a Provider because the store lives outside the tree; any component reads it directly with useCartStore. The only Providers are module 2's (theme and user), which do use Context. Store and Context coexist, each for its own.
Worked example: a session, executed
We're going to run the cart simulating a shopping session: first we print the classification table, then the user adds and removes products —including a free sticker— with three subscribers watching (CartBadge to the count, CartTotal to the total, Cart to the detail), and at the end we compare the re-renders against Context. You'll see each subscriber react only to its column, and that the free product (which raises the count but not the total) doesn't wake the CartTotal:
// ---- mini Zustand-style store + subscription with selector (no dependencies) ----
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)}`;
// ---- 1) the classification table of Mercado state ----
const stateMap = [
['cart', 'global client, changes OFTEN', 'store + selectors (this module)'],
['theme / user', 'global client, changes little', 'Context (M2)'],
['products', 'SERVER state (cache)', 'React Query (M4-M6)'],
['search / filters', 'URL state', 'searchParams (M7)'],
['open dropdown', 'local to a component', 'useState (lift)'],
];
console.log('=== Mercado state classification ===\n');
console.log(' Piece | What it is | Tool');
console.log(' --------------------|-------------------------------------|---------------------------------');
stateMap.forEach(([p, w, t]) => console.log(` ${p.padEnd(19)} | ${w.padEnd(35)} | ${t}`));
// ---- 2) the cart store: state + actions ----
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 selectTotal = (s) => s.items.reduce((sum, it) => sum + it.priceCents, 0);
const selectItems = (s) => s.items;
// ---- 3) three subscribers with selectors ----
let badge = 0, totalR = 0, cart = 0;
subscribeWithSelector(useCartStore, selectCount, (c) => { badge++; console.log(` CartBadge -> (${c})`); });
subscribeWithSelector(useCartStore, selectTotal, (t) => { totalR++; console.log(` CartTotal -> ${formatPrice(t)}`); });
subscribeWithSelector(useCartStore, selectItems, (items) => {
cart++;
console.log(` Cart -> ${items.map((it) => it.name).join(', ') || '(empty)'}`);
});
const { addItem, removeItem, clear } = useCartStore.getState();
console.log('\n=== Shopping session (the cart in the store, with selectors) ===\n');
console.log('addItem(Wireless Mouse, 2599) -> count, total and items change:');
addItem({ id: 1, name: 'Wireless Mouse', priceCents: 2599 });
console.log('\naddItem(Free Sticker, 0) -> count and items change; total does NOT:');
addItem({ id: 2, name: 'Free Sticker', priceCents: 0 });
console.log('\naddItem(Mechanical Keyboard, 8900) -> count, total and items change:');
addItem({ id: 3, name: 'Mechanical Keyboard', priceCents: 8900 });
console.log('\nremoveItem(2 = Free Sticker) -> count and items change; total does NOT:');
removeItem(2);
console.log('\nclear() -> all three change:');
clear();
console.log(`\n Re-renders with selectors -> CartBadge: ${badge} | CartTotal: ${totalR} | Cart: ${cart}`);
// ---- 4) contrast with Context (no selectors): everyone re-renders always ----
console.log('\n=== The same script in Context (no selectors): everyone re-renders ALWAYS ===\n');
const ctx = createStore((set) => ({
items: [],
addItem: (p) => set((s) => ({ items: [...s.items, p] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((it) => it.id !== id) })),
clear: () => set({ items: [] }),
}));
let cBadge = 0, cTotal = 0, cCart = 0;
ctx.subscribe(() => cBadge++);
ctx.subscribe(() => cTotal++);
ctx.subscribe(() => cCart++);
const ca = ctx.getState();
ca.addItem({ id: 1, name: 'Wireless Mouse', priceCents: 2599 });
ca.addItem({ id: 2, name: 'Free Sticker', priceCents: 0 });
ca.addItem({ id: 3, name: 'Mechanical Keyboard', priceCents: 8900 });
ca.removeItem(2);
ca.clear();
console.log(` Re-renders with Context -> CartBadge: ${cBadge} | CartTotal: ${cTotal} | Cart: ${cCart}`);
console.log(`\n CartTotal: ${totalR} re-renders with selector vs ${cTotal} with Context.`);
console.log(' With selectors, the Free Sticker (total unchanged) didn\'t touch CartTotal.');
console.log('\n=== products did NOT go through the store: server state -> React Query (M4-M6) ===');
What to expect. When you run the file with Node, the output is exactly this:
=== Mercado state classification ===
Piece | What it is | Tool
--------------------|-------------------------------------|---------------------------------
cart | global client, changes OFTEN | store + selectors (this module)
theme / user | global client, changes little | Context (M2)
products | SERVER state (cache) | React Query (M4-M6)
search / filters | URL state | searchParams (M7)
open dropdown | local to a component | useState (lift)
=== Shopping session (the cart in the store, with selectors) ===
addItem(Wireless Mouse, 2599) -> count, total and items change:
CartBadge -> (1)
CartTotal -> $25.99
Cart -> Wireless Mouse
addItem(Free Sticker, 0) -> count and items change; total does NOT:
CartBadge -> (2)
Cart -> Wireless Mouse, Free Sticker
addItem(Mechanical Keyboard, 8900) -> count, total and items change:
CartBadge -> (3)
CartTotal -> $114.99
Cart -> Wireless Mouse, Free Sticker, Mechanical Keyboard
removeItem(2 = Free Sticker) -> count and items change; total does NOT:
CartBadge -> (2)
Cart -> Wireless Mouse, Mechanical Keyboard
clear() -> all three change:
CartBadge -> (0)
CartTotal -> $0.00
Cart -> (empty)
Re-renders with selectors -> CartBadge: 5 | CartTotal: 3 | Cart: 5
=== The same script in Context (no selectors): everyone re-renders ALWAYS ===
Re-renders with Context -> CartBadge: 5 | CartTotal: 5 | Cart: 5
CartTotal: 3 re-renders with selector vs 5 with Context.
With selectors, the Free Sticker (total unchanged) didn't touch CartTotal.
=== products did NOT go through the store: server state -> React Query (M4-M6) ===
Read the output from start to finish, because in it is the whole module working together.
The classification table opens the project with the decision made explicit: the cart goes in the store (global client, changes often); the theme and the user in Context (they change little); the products in React Query (server); the search in the URL; an open menu in useState. Each piece, its tool. It's module 6's lesson applied to Mercado, and the map of what this project builds (the first row) and what it points ahead (the rest).
The session executes the pattern. Follow the CartTotal's column, which is the revealing one. With addItem(Mouse) the total goes up to $25.99 → the CartTotal reacts. With addItem(Free Sticker, 0) the count goes to 2 and the list grows (the CartBadge and the Cart react), but the total is still $25.99 → the CartTotal prints nothing, its column didn't change. With addItem(Keyboard) the total jumps to $114.99 → the CartTotal reacts. With removeItem(Free Sticker) the sticker leaves: the count drops to 2 and the list changes, but the total is still $114.99 (removing something of $0 doesn't move it) → the CartTotal stays quiet again. With clear() everything goes to zero → the three react. The measured result: CartBadge 5 re-renders, CartTotal 3, Cart 5. Each subscriber reacted only when its column changed.
The contrast with Context closes the thesis. The same script, but subscribed to the whole state: the three re-render on all 5 actions. The CartTotal goes from 3 re-renders (with a selector) to 5 (with Context): two useless re-renders, the free sticker ones (add and remove), where its data didn't change but it re-rendered anyway. Those two extra re-renders, for a single component and a single short session, are the waste the selector eliminates. Multiply it by the components of a real storefront and the dozens of changes of a cart, and you have the reason, measured, for the whole module.
And the closing says it all: the products never went through the store. They aren't client state; they're a cached copy of server data, and their tool is React Query (modules 4-6). That's the whole module in one run: classify (table), set up what changes often in a store (the cart) with the correct pattern (state + actions outside the tree, fine selectors), and leave out what doesn't belong to it (the server's catalog).
Common mistakes
Putting all the global state in the cart store. What happens: you add to useCartStore the theme, the user and even the products, "since it's the app's store". Why it happens: having a store invites centralizing everything. How to detect it: the cart store mixes items (changes often) with the theme (changes little) and with products (server's). How to fix it: each piece in its tool —the theme and the user in Context (M2), the products in React Query (M4-M6), and the store only for the cart—. A store isn't a drawer for all the state; it's the tool for client state that changes often.
Subscribing the consumers to the whole state (losing the selector). What happens: you write const cart = useCartStore() in the CartBadge, the CartTotal and the Cart, and the three re-render on every change of the cart —just like Context, the problem you came to solve—. Why it happens: it's shorter not to write the selector. How to detect it: the CartTotal re-renders when a $0 item comes in. How to fix it: each consumer with its fine selector (selectCount, selectTotal, selectItems), and those that only write (ProductCard) with the action (s => s.addItem). The selector is the reason for having chosen a store; without it, you gained nothing.
Storing count/total in the store instead of deriving them. What happens: besides items, you store count and total as slices, and you have to update them on each action —until one day addItem raises items but forgets to raise count, and the badge lies—. Why it happens: it seems comfortable to have the numbers ready. How to detect it: the CartBadge shows a number that doesn't match the real quantity of items. How to fix it: store only items and derive count/total in the selectors (L3, L5). One source, impossible to desync.
Exercises
Exercise 1 — Add "empty and notify". You want a checkout() action that empties the cart and, in addition, stores the number of items purchased in a lastPurchaseCount slice (to show "You bought 3 products"). (a) Write the action. (b) What selector would a PurchaseConfirmation component that shows that number use? (c) Why is lastPurchaseCount stored (unlike count, which is derived)?
See solution
(a)
checkout: () => set((s) => ({
lastPurchaseCount: s.items.length, // stores how many there were BEFORE emptying
items: [], // empties the cart
})),
(And the store's initial state would include lastPurchaseCount: 0.)
(b) useCartStore((s) => s.lastPurchaseCount) — a primitive, re-render only when it changes.
(c) count is derived from items (items.length), so storing it would be redundant and desyncable. But lastPurchaseCount can't be derived from items: it's a data that captures a moment of the past (how many there were before emptying), and after the clear the items is empty. Since it isn't a function of the current state, it's base state and it's stored. The rule: derive what's a function of the current state; store what you can't recompute from it.
Exercise 2 — Predict the re-renders. With the project's store and the three subscribers (CartBadge→count, CartTotal→total, Cart→items), you run: (1) addItem(A, 1000), (2) addItem(B, 0), (3) removeItem(A), (4) addItem(C, 500). How many times does each subscriber re-render?
See solution
Let's follow the count, the total and the items at each step:
- (1) addItem(A, 1000): count 0→1, total 0→1000, items change → the three react.
- (2) addItem(B, 0): count 1→2, total 1000→1000 (B is free), items change →
CartBadgeandCartyes,CartTotalno. - (3) removeItem(A): count 2→1, total 1000→0 (A leaves, B at $0 remains), items change → the three react.
- (4) addItem(C, 500): count 1→2, total 0→500, items change → the three react.
Totals: CartBadge → 4 (the count changed in the 4 steps), CartTotal → 3 (the total changed in 1, 3, 4; not in 2), Cart → 4 (the items changed in the 4). With Context, the three would re-render 4 times: the CartTotal would have 1 useless re-render (step 2's).
Exercise 3 — Which lesson solves what? The project touches the whole module. For each piece, say which lesson the underlying concept is from: (a) the useCartStore declared in the module, outside every component; (b) addItem doing [...s.items, product] instead of push; (c) the CartTotal that doesn't re-render when a $0 item comes in; (d) the ProductCard that reads s => s.addItem and doesn't re-render due to the cart; (e) the products staying out of the store.
See solution
- (a)
useCartStoreoutside every component — the store lives outside React (L2): a single source of truth in the module scope, that any component reads without props nor a Provider. - (b)
addItemwithout mutating — state and actions together (L3): the actions produce new state (different identity) so the store detects the change and notifies. - (c)
CartTotalthat stays quiet with a $0 item — subscribing with a selector (L4): the selector compares the slice (the total); if it didn't change, it doesn't re-render. - (d)
ProductCardwiths => s.addItem— selectors in practice / stable actions (L3, L5): the actions have stable identity, so a component that only dispatches doesn't re-render due to the state. - (e) products out of the store — store vs Context vs lifting (L6): server state isn't client's; it goes in React Query (M4-M6), not the store.
The project integrates the module: classify (L6), set up the store outside the tree (L2), actions without mutating (L3), fine selectors (L4-L5), apply to Mercado (L7). And no piece needed to put in the store what didn't belong to it.
Summary and next step
In this mini-project you managed Mercado's cart end to end with a store. You started with the classification table —cart → store; theme/user → Context (M2); products → React Query (M4-M6); search → URL (M7); menu → local—, which makes the decision explicit before writing code. You set up the cart as a store with items + addItem/removeItem/clear and the selectors selectCount/selectTotal/selectItems, outside the tree, with each component subscribed to its slice. And you executed it in a shopping session with three subscribers, where each reacted only to its column —the CartTotal stayed quiet with the $0 items (3 re-renders with a selector vs 5 with Context)— and the products were deliberately left out of the store. That's the whole module working together: classify, set up what changes often in a store with the correct pattern (state + actions outside the tree, fine selectors), and leave out what doesn't belong to it.
Before closing the module you should be able to: classify an app's state and place the cart in a store; set up a store with state, actions (without mutating) and selectors outside the tree; subscribe each component to the minimal slice it uses (including the action, for those that only write); and explain why the products, the theme and the search don't go in the store.
And where the guide goes next. With this module you finished global client state: the stable in Context (M2), what changes often in a store (M3). But in every lesson it appeared, flagged and left out, the most important box of all: server state —Mercado's products, which aren't yours, but a cached copy of data that live in the backend—. Module 4 opens that box: why server state is different (it goes stale, you have to refetch it, it's shared among users, it can fail), and why managing it with useState + useEffect —or putting it in a store, as you just learned not to do— is wrong. It's the heart of the guide, and the reason for its thesis: most "state problems" are misclassified server state. Then will come React Query (M5), the mutations (M6) and the URL (M7), until each box has its tool.
Resources
- Zustand, official documentation — docs.pmnd.rs/zustand/getting-started/introduction. The complete reference for
create, the actions and the selectors this project assembles. In English. - TkDodo, "Working with Zustand" — tkdodo.eu/blog/working-with-zustand. The best practices this mini-project applies: store outside the tree, stable actions, atomic selectors, and not putting server state. In English.
- React, "Scaling Up with Reducer and Context" — react.dev/learn/scaling-up-with-reducer-and-context. The pattern of global state with pure React, to contrast with the store and understand what it gains with the selectors. In English.
- TanStack Query, "Overview" — tanstack.com/query/latest/docs/framework/react/overview. The destination of the products this project leaves out of the store: server state and its tool, which opens module 4. In English.