Module 8: Project Manage Mercados State

Layer 2: the cart in the global store

Overview

Layer 1 connected the stable —theme and user in Context—. Now we connect the global piece that changes often: the cart. Lesson 2's table sent it to a store (Zustand) and not to Context, because of its second decision (changesOften: true): the cart changes with each "Add to cart", "Remove", "Clear", and Context would re-render its whole consuming subtree on each click —and it has no selectors for each component to react only to what it uses—. This lesson sets up the cart as a complete store —the state (items), the actions (addItem, removeItem, clear) and the selectors (selectCount, selectTotal, selectItems)— and runs a shopping session where three subscribers each react to their column: the CartBadge to the count, the CartTotal to the total, the Cart to the detail. You'll see, executed, that a free product (0 cents) moves the count and the list but not the total, and that's why the CartTotal does not re-render in that step. That's the advantage of the store that Context doesn't give: each component its selector, each selector its column.

Like every capstone lesson, it teaches no new API: it gathers what module 3 gave you —the store outside the tree, the actions that produce new state, the fine-grained selectors— and puts it to work as layer 2 of the complete storefront. The theme already flows through its duct (layer 1); now the cart finds its tool.

Connection with the module. It's layer 2 of the construction. Lesson 3 connected Context (theme/user); this one connects the store (cart). The following ones connect the products with React Query (L5), the mutation (L6) and the filters in the URL (L7), until lesson 8 brings them together in the real App. Here, the module of origin is 3. And a boundary we underline: the products that are added to the cart come from the server —in the real storefront they arrive with React Query, layer 3—; here we treat them as fixed data to focus on the cart, which is client state.

An analogy: the cart's board, with three columns

In the freshly finished house, the cart is the cash register that rings with each sale: too often for a central system. Its tool is a board hung on the wall, with the cart's source of truth and several columns that different employees care about.

The first column says, in big letters, how many products there are: a single number, "3". The counter employee (the CartBadge) looks at it, the one who puts the little number on the cart icon. Only that number matters to them. The second column says the total to pay: "$114.99". The register employee (the CartTotal) looks at it. And the third is the detail: each product with its price. The billing one (the Cart) looks at it, assembling the screen line by line.

The three look at the same board —the same cart— but each at their column. And there's a fourth character, the deliveryperson (the ProductCard), who doesn't even look at the board: they only have a button to jot down a new product (addItem). When the deliveryperson jots down a free sticker, the count column goes up (and the counter one updates it) and the detail one grows (and the billing one redoes it), but the total column doesn't move (the sticker costs $0.00), so the register one doesn't even look up. Each employee reacts only when their column changes. That board with its columns and its jot-down button is the cart store: items (the truth), selectCount/selectTotal/selectItems (the columns), addItem (the button).

The real React code: the cart store and its consumers

This is how layer 2 is set up in real React, with Zustand. The store lives outside the tree; each component subscribes to its selector:

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 stable primitives).
const selectCount = (s) => s.items.length;
const selectTotal = (s) => s.items.reduce((a, it) => a + it.priceCents, 0);
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>;
}

// CartTotal: only the total. Re-renders only when the total changes.
function CartTotal() {
  const total = useCartStore(selectTotal);
  return <strong>{formatPrice(total)}</strong>;
}

// 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)
  return (
    <aside>
      {items.map((it) => (
        <div key={it.id}>
          {it.name} — {formatPrice(it.priceCents)}
          <button onClick={() => remove(it.id)}>Remove</button>
        </div>
      ))}
    </aside>
  );
}

// ProductCard: only writes. Reads the addItem action (stable) -> doesn't re-render for 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/selectTotal/selectItems are the three columns; the CartBadge, the CartTotal and the Cart each look at theirs; and the ProductCard only uses the jot-down button (addItem), without reading anything of the cart —that's why it doesn't re-render when the cart changes—. No component receives the cart by props: they all talk directly to the store, each with its selector, even if they live in distant branches of the tree (the badge in the Header, the Cart in a panel).

Worked example: the shopping session, executed

We execute the logic in Node: we reuse module 3's mini-store (with createStore and subscribeWithSelector) and set up the cart with its three selectors. We subscribe the CartBadge to selectCount, the CartTotal to selectTotal and the Cart to selectItems, and run a session —add mouse, add keyboard, add a free sticker, remove the mouse—. At each step we print what each subscriber 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. Outside the tree.
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((a, it) => a + it.priceCents, 0);
const selectItems = (s) => s.items;

// Three consumers, each subscribed ONLY to its column:
let badge = 0, totalR = 0, cartR = 0;
subscribeWithSelector(useCartStore, selectCount, (c) => { badge++; console.log('     CartBadge -> (' + c + ')'); });
subscribeWithSelector(useCartStore, selectTotal, (t) => { totalR++; console.log('     CartTotal -> ' + formatPrice(t)); });
subscribeWithSelector(useCartStore, selectItems, (items) => {
  cartR++;
  const lines = items.map((it) => it.name + ' ' + formatPrice(it.priceCents)).join(' | ') || '(empty)';
  console.log('     Cart      -> ' + lines);
});

const { addItem, removeItem } = useCartStore.getState();

console.log('=== Capstone layer 2: the cart in the store (selectors) ===\n');
console.log('addItem(Wireless Mouse $25.99):');
addItem({ id: 'p1', name: 'Wireless Mouse', priceCents: 2599 });
console.log('\naddItem(Mechanical Keyboard $89.00):');
addItem({ id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 });
console.log('\naddItem(Free Sticker $0.00):  <- bumps the count, NOT the total');
addItem({ id: 'p3', name: 'Free Sticker', priceCents: 0 });
console.log('\nremoveItem(p1 = Mouse):');
removeItem('p1');

console.log('\n--- Re-renders per subscriber ---');
console.log('  CartBadge (selectCount): ' + badge);
console.log('  CartTotal (selectTotal): ' + totalR);
console.log('  Cart      (selectItems): ' + cartR);
console.log('\nThe free sticker moved count and list, but NOT the total: each selector, its column.');

What to expect. When you run the file with Node, the output is exactly this:

=== Capstone layer 2: the cart in the store (selectors) ===

addItem(Wireless Mouse $25.99):
     CartBadge -> (1)
     CartTotal -> $25.99
     Cart      -> Wireless Mouse $25.99

addItem(Mechanical Keyboard $89.00):
     CartBadge -> (2)
     CartTotal -> $114.99
     Cart      -> Wireless Mouse $25.99 | Mechanical Keyboard $89.00

addItem(Free Sticker $0.00):  <- bumps the count, NOT the total
     CartBadge -> (3)
     Cart      -> Wireless Mouse $25.99 | Mechanical Keyboard $89.00 | Free Sticker $0.00

removeItem(p1 = Mouse):
     CartBadge -> (2)
     CartTotal -> $89.00
     Cart      -> Mechanical Keyboard $89.00 | Free Sticker $0.00

--- Re-renders per subscriber ---
  CartBadge (selectCount): 4
  CartTotal (selectTotal): 3
  Cart      (selectItems): 4

The free sticker moved count and list, but NOT the total: each selector, its column.

Read the session step by step, because it's the heart of layer 2.

addItem(Mouse): the count goes to 1 (the CartBadge paints (1)), the total to $25.99 (the CartTotal), and the list has one item (the Cart). The three react because the three columns changed. addItem(Keyboard): count to 2, total to $114.99 (2599 + 8900 = 11499 cents), list with two products. Again the three.

addItem(Free Sticker $0.00): here's the lesson. The count goes up to 3 (the CartBadge paints (3)) and the list grows (the Cart adds the sticker), but the total stays at $114.99 —the sticker costs 0—, so the CartTotal prints nothing in this step. Its selector returned the same 11499 as before, Object.is detected it equal, and the subscriber did not re-render. That absent line is the visible proof that the selectors work: the CartTotal reacts only when the total changes, not when "something" of the cart changes.

removeItem(p1): the mouse leaves; count to 2, total to $89.00, list with keyboard and sticker. The three react (the three columns changed).

And the re-renders confirm the mechanic: CartBadge: 4 (the count changed in the 4 steps), Cart: 4 (the list changed in the 4), but CartTotal: 3 —it did not re-render in the free-sticker step—. Three subscribers to the same board, each with its re-render count, each reacting only to its column. That's what Context can't give and the store can: subscription by selector. With layer 2 connected this way, the client state that changes often already lives in its box, without excess re-renders.

Deeper: how this layer is assembled and what delimits it

Store items, derive everything else. The store stores one base slice: items. The count, the total, whether it's empty, whether there's free shipping —all of that are selectors that derive from items—. There's no count nor total stored to synchronize; there's one source and many views of it. That's why it's impossible for the badge to show "3" while the list has 2 products: the two 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), its selector always returns the same function, so the ProductCard never re-renders for cart changes. A component that only dispatches shouldn't re-render when the state changes, and with the store that comes for free. (In Context, this required the "separate actions from state" refinement; here it's automatic.)

The store connects distant components without plumbing. The CartBadge lives in the Header; the CartTotal and the Cart, in a side panel; the add button, inside each ProductCard of the grid. Three very separate branches of the tree, and the three touch the same cart without any common ancestor passing the state down by props nor providing a Context. The store, by living outside the tree, is accessible from any branch equally. That's why, in lesson 2's App, the cart has no <CartProvider>: it doesn't need one.

The boundary with layer 3, made explicit. In this session, we wrote the products ({ id, name, priceCents }) by hand. In the real Mercado, that list shown in the grid does not live in the store: it comes from the server (an API), 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 —layer 3, lesson 5—. Putting the catalog in the cart store would reopen module 4's stale-copies bug. Keep the boundary clean.

flowchart TD
    Store["useCartStore&nbsp;&nbsp;(outside the tree: items + addItem/removeItem/clear)"]
    Header --> Badge["CartBadge&nbsp;&nbsp;(selectCount)"]
    Panel --> Total["CartTotal&nbsp;&nbsp;(selectTotal)"]
    Panel --> Cart["Cart&nbsp;&nbsp;(selectItems + s =&gt; s.removeItem)"]
    Grid[ProductGrid] --> Card["ProductCard&nbsp;&nbsp;(s =&gt; s.addItem)"]

    Badge -. "reads count" .-> Store
    Total -. "reads total" .-> Store
    Cart -. "reads items / removeItem" .-> Store
    Card -. "addItem" .-> Store

Common mistakes

Subscribing a component to the whole cart. What happens: the CartTotal does const cart = useCartStore() (without selector) or reads items, and then re-renders every time the cart changes —including the free-sticker step, where its value didn't change—. Why it happens: "I need the store". How to detect it: in a run like the one above, the CartTotal would re-render 4 times instead of 3. How to fix it: subscribe to the selector of your column (selectTotal). The selector returns a stable primitive, and Object.is cuts the re-render when it doesn't change.

Putting the CartBadge inside the Cart to "share" the count. What happens: since the two use the cart, someone nests them or passes the count by prop —but they live in 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 by props. How to detect it: the count crosses components without a natural parent-child relationship. How to fix it: with a store, 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.

Putting the catalog products in the cart store. What happens: you add to the useCartStore a products slice with the catalog list (which comes from the API), "since I'm handling products 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, and it stays stale. 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 (layer 3)—. The cart store stores only items.

Exercises

Exercise 1 — Predict the re-renders. With the example's three subscribers (CartBadgeselectCount, CartTotalselectTotal, CartselectItems), the user: (1) adds USB-C Cable (999), (2) adds a Free Sticker (0), (3) removes the USB-C Cable. For each step, say which of the three re-render and why.

See solution
  • (1) addItem(USB-C Cable, 999)the three. Count 0→1 (CartBadge), total 0→999 (CartTotal), list changes (Cart).
  • (2) addItem(Free Sticker, 0)CartBadge and Cart, not CartTotal. Count 1→2 (CartBadge), list changes (Cart), but the total stays at 999 (the sticker costs 0) → selectTotal returns the same, CartTotal does not re-render.
  • (3) removeItem(USB-C Cable)the three. Count 2→1 (CartBadge), total 999→0 (CartTotal), list changes (Cart).

Total re-renders: CartBadge 3, Cart 3, CartTotal 2. The free sticker is, again, the case that separates the total from the other columns: each selector reacts only to its slice.

Exercise 2 — Add a column with a selector. Mercado wants to show "Free shipping" when the total reaches $50.00 (5000 cents). (a) Do you need a new action or is a selector enough? (b) Write the selector. (c) When does the FreeShippingBanner that uses it re-render?

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 action nor new slice is needed.

(b) and (c):

const selectHasFreeShipping = (s) =>
  s.items.reduce((a, it) => a + it.priceCents, 0) >= 5000;

function FreeShippingBanner() {
  const hasFreeShipping = useCartStore(selectHasFreeShipping);
  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 5000 threshold (from false to true or the other way), not on each item added below or above the threshold. Nothing to store nor synchronize: it derives from items, like all the columns.

Exercise 3 — Place each data in its layer. For each piece, say its tool and its capstone layer, 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 → store (layer 2). Global client, changes often; this lesson's case.
  • (b) Catalog → React Query (layer 3, lessons 5-6). Server state (cached copy of the API). It doesn't go in the store nor in Context.
  • (c) Theme → Context (layer 1, lesson 3). Global client, changes little.
  • (d) Cart panel expanded → local useState. It's local UI of the component that opens the panel; the app doesn't share it. (Note: the cart's items are indeed global and persist even if the panel collapses —they live in the store, not in the component—.)
  • (e) User → Context (layer 1, lesson 3). Global client, changes little.

The key: the cart (changes often) goes in the store; the catalog (from the backend) in React Query; the stable global (theme, user) in Context; the local (open panel) in useState. Each box, its tool and its layer.

Summary and next step

In this lesson you connected the capstone's layer 2: the cart as a complete store —items with addItem/removeItem/clear and the selectors selectCount/selectTotal/selectItems—, outside the tree. You anchored it with the three-column board —count (counter), total (register), detail (billing), plus the jot-down button (deliveryperson)— and executed it in a shopping session where the free product proved the selectors: it moved count and list but not the total, so the CartTotal re-rendered 3 times and the others 4. The client state that changes often already lives in its box, without excess re-renders.

Before moving on you should be able to: set up a global 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 the ones that only write); explain why the store connects distant components without prop drilling; and keep the catalog (server) out of the store.

Lesson 5 connects layer 3, the central box and the most different one: the products with React Query. You're going to see the catalog shared by several views with a single fetch (dedup), the search with the term in the queryKey, and the background revalidation (stale-while-revalidate). The cart already lives in its store; now the server state —the one that isn't yours, but a cached copy of the backend— finds its tool.

Resources