Module 8: Project Build Mercados Storefront
The cart lifted in the `App`
Overview
The search already lives (lesson 4); time for the storefront's other half: the cart. Until now the Cart showed stage-dressing items. In this lesson the cart becomes real state, with two decisions that come from module 7. First: where it lives. The cart is touched by two sibling components —the ProductList (with its ProductCards that add) and the Cart (which shows and removes)—, and in React a component can't read its sibling's state; so the cart goes up to the common ancestor, the App, and comes down by props to both. Second: how it changes. The cart's logic isn't trivial —adding a product that's already there raises its quantity, removing lowers it or deletes the line, clearing wipes everything—, so we consolidate it in a pure function, the cartReducer of the form (state, action) => newState, and we connect it to the App with useReducer. And since the reducer is pure, we execute it without a browser: a sequence of actions, the state and the total in cents, literal, and the proof that it doesn't mutate.
Connection with the module. It's the capstone's fourth layer, and it's module 7 (with 3 underneath) applied to the storefront. Lifting the state —the cart in the App, not in a sibling— is M7 (lessons 2-3). The cartReducer as a pure function with add/remove/clear is M7 (lesson 4). Connecting it with useReducer and dispatching actions with dispatch is M7 (lesson 5), mounted on module 3's useState (the state that persists and triggers a re-render). And all of module 3's immutability (don't mutate; create new structures) reappears as the reducer's hard rule. Here the reducer is still pure, isolated JavaScript; in lesson 6 we connect it to the siblings' callbacks.
An analogy: the cashier with their rulebook
Imagine a store's checkout and the person who operates it. Their job is utterly pure: they don't improvise. An operation arrives —"add this product", "remove this one", "cancel the purchase"— and, looking at the current ticket and the checkout's rulebook, they compute the new ticket. If the ticket already has a mouse and "add another mouse" arrives, they don't write a second line: they raise the quantity of the mouse to 2. If "remove the mouse" arrives and there were two, it goes down to one; if there was one, they delete the line. If "cancel" arrives, the ticket ends up empty. The cashier remembers nothing hidden (the current ticket is always given to them), consults nothing from outside (not the time, not the weather), and doesn't cross out the old ticket: they produce a new one. Give them the same ticket and the same operation a thousand times, and a thousand times they'll give the same result.
The cartReducer is that cashier. The current ticket is the state (the cart now). The operation is the action ({ type: 'add', product }, { type: 'remove', id }, { type: 'clear' }). The rulebook is the function's body: for each operation type, how the new cart is computed. And the new ticket is what it returns. The whole signature is (state, action) => newState: give me the cart and what happened, and I give you the resulting cart. What's valuable about this cashier is that they're predictable and isolated: since they don't depend on anything external nor remember anything hidden, you can test them alone —you give them an input cart, an operation, and you check the output cart—, without setting up the store. That's the property that lets us execute the cart's logic in Node.
Worked example: the cartReducer, connected and run
First, how it looks in real React. The App lifts the cart with useReducer(cartReducer, []) and passes down what each sibling needs:
function App({ products }) {
const [query, setQuery] = useState('');
const [cart, dispatch] = useReducer(cartReducer, []); // the cart lives HERE, in the common ancestor
const visible = getVisibleProducts(products, query);
return (
<main className="storefront">
<SearchBar query={query} onSearch={setQuery} />
{/* passes down to ProductList the way to ADD */}
<ProductList
products={visible}
onAddToCart={(product) => dispatch({ type: 'add', product })}
/>
{/* passes down to the sibling Cart the ITEMS and the way to REMOVE / CLEAR */}
<Cart
items={cart}
onRemoveFromCart={(id) => dispatch({ type: 'remove', id })}
onClear={() => dispatch({ type: 'clear' })}
/>
</main>
);
}
Read it with the cashier analogy. The App is the checkout: it has the cart (via useReducer) and it's the only one that changes it. The children are the ones who request operations: the ProductList receives onAddToCart (its way of saying "add"), the Cart receives the items (to show) and onRemoveFromCart/onClear (its ways of saying "remove" / "cancel"). No child carries the logic of how the cart changes: they only dispatch operations (dispatch({ type, ... })), and the reducer —the cashier— decides. A single cart, in the App, read by both siblings.
And this is the cartReducer, the pure function with the rulebook —the same one you'll write in React, here in pure JavaScript so we can execute it—:
// The cartReducer: a PURE function (state, action) => newState.
// Cart state: an array of lines { id, name, priceCents, qty }.
function cartReducer(state, action) {
switch (action.type) {
case 'add': {
const line = state.find((item) => item.id === action.product.id);
if (line) {
// already in the cart: raise the quantity WITHOUT mutating (map -> new array)
return state.map((item) =>
item.id === action.product.id ? { ...item, qty: item.qty + 1 } : item
);
}
// new product: add a line with qty 1 (new array)
return [...state, { ...action.product, qty: 1 }];
}
case 'remove': {
const line = state.find((item) => item.id === action.id);
if (line && line.qty > 1) {
// lower the quantity by 1 (without mutating)
return state.map((item) =>
item.id === action.id ? { ...item, qty: item.qty - 1 } : item
);
}
// qty reaches 0: remove the whole line (filter -> new array)
return state.filter((item) => item.id !== action.id);
}
case 'clear':
return [];
default:
return state;
}
}
Each branch produces a new cart —state.map(...), [...state, ...], state.filter(...) create new arrays, none touches the input state—. And the default: return state protects against unknown actions. Now let's run it with a sequence that exercises the three actions and the quantities, with utilities for the total and to describe the cart:
function cartTotal(state) { return state.reduce((s, i) => s + i.priceCents * i.qty, 0); }
function formatPrice(cents) { return '$' + (cents / 100).toFixed(2); }
function describe(state) {
const lines = state.map((i) => `${i.name} x${i.qty}`).join(', ');
return `[${lines}] total ${formatPrice(cartTotal(state))}`;
}
const MOUSE = { id: 'p1', name: 'Wireless Mouse', priceCents: 2599 };
const HUB = { id: 'p3', name: 'USB-C Hub', priceCents: 3499 };
const LAMP = { id: 'p5', name: 'Desk Lamp', priceCents: 1999 };
const NAMES = { p1: 'Wireless Mouse', p3: 'USB-C Hub', p5: 'Desk Lamp' };
const actions = [
{ type: 'add', product: MOUSE },
{ type: 'add', product: HUB },
{ type: 'add', product: MOUSE },
{ type: 'remove', id: MOUSE.id },
{ type: 'add', product: LAMP },
{ type: 'clear' },
];
console.log('=== The cart lifted in App: cartReducer (state, action) => newState ===\n');
let cart = [];
console.log(`${'initial'.padEnd(24)}${describe(cart)}`);
for (const action of actions) {
cart = cartReducer(cart, action);
const label =
action.type === 'add' ? `add ${action.product.name}` :
action.type === 'remove' ? `remove ${NAMES[action.id]}` : 'clear';
console.log(`${label.padEnd(24)}${describe(cart)}`);
}
console.log('\n=== Purity: the reducer does NOT mutate the previous cart ===');
const before = [{ ...MOUSE, qty: 1 }];
const after = cartReducer(before, { type: 'add', product: MOUSE });
console.log(`before: ${describe(before)}`);
console.log(`new: ${describe(after)}`);
console.log(`same reference? ${before === after}`);
What to expect. When you run the file with Node, the output is exactly this:
=== The cart lifted in App: cartReducer (state, action) => newState ===
initial [] total $0.00
add Wireless Mouse [Wireless Mouse x1] total $25.99
add USB-C Hub [Wireless Mouse x1, USB-C Hub x1] total $60.98
add Wireless Mouse [Wireless Mouse x2, USB-C Hub x1] total $86.97
remove Wireless Mouse [Wireless Mouse x1, USB-C Hub x1] total $60.98
add Desk Lamp [Wireless Mouse x1, USB-C Hub x1, Desk Lamp x1] total $80.97
clear [] total $0.00
=== Purity: the reducer does NOT mutate the previous cart ===
before: [Wireless Mouse x1] total $25.99
new: [Wireless Mouse x2] total $51.98
same reference? false
Read the sequence operation by operation, verifying the cashier's rulebook.
- initial: empty cart,
total $0.00. - add Wireless Mouse: the mouse wasn't there → new line
Wireless Mouse x1. Total$25.99(2599 cents, formatted). - add USB-C Hub: wasn't there either → second line. Total
$60.98(2599 + 3499). - add Wireless Mouse (again): the mouse was already there → raises its quantity to
x2, without duplicating the line. Total$86.97(2599×2 + 3499). Here you see theaddrule that isn't a plainpush. - remove Wireless Mouse: the mouse had
qty 2→ goes down to x1 (it's not removed, because 1 remains). Total$60.98(2599 + 3499). The "lower quantity" branch ofremove. - add Desk Lamp: new product → third line
x1. Total$80.97(6098 + 1999). - clear: empty cart,
total $0.00.
Each output line is the reducer applying its rulebook to an operation: it raised quantities, lowered quantities, added lines and cleared, and the total in cents added up at every step. It never improvised; always (state, action) => newState.
And look at the purity proof at the end, which is half a lesson. We take a cart before with the mouse at qty 1, and we apply add mouse to it. The result after has the mouse at qty 2 —correct—. But notice before: it's still at qty 1. The reducer didn't touch the cart it received; it produced a new one. And before === after is false: they're different arrays, different references. That's exactly what React needs to "see" the change (M3: React compares references). A reducer that had done line.qty++ would have mutated before, left the same reference, and React wouldn't have re-rendered. Purity isn't a luxury: it's what makes the reducer work with React.
Going deeper: why the cart is lifted and handled with a reducer
It's lifted because two siblings share it. The ProductList (where the "Add to cart" buttons are) and the Cart (which shows the cart and removes) are sibling components: both hang from the App, neither is inside the other. In React, a component can't read its sibling's state. If each one kept its own copy of the cart, they'd desync: you add from the ProductList and the Cart keeps showing zero. Module 7's solution is to lift: the cart lives in the common ancestor (App) and comes down by props, so there's a single source of truth that both siblings read. No more contradiction.
It's handled with a reducer because its logic has rules. The cart isn't a number: it's a list of lines with quantities, and each action transforms it with rules (raise quantity if already there, lower or delete, clear). If that logic lived scattered across loose handlers —one that finds the line and decides, another that copies the array without mutating—, it would become a tangle hard to follow and to test. The reducer consolidates it in one place, with the signature (state, action) => newState. The children's handlers stay one-liners (they just dispatch), and the whole recipe lives together, isolated and verifiable. There's module 7's criterion: useReducer when the state has several sub-values that change together with rules —the cart is the textbook case—.
The hard rule: a reducer NEVER mutates the state. Every branch that changes something returns a new structure: changing an item → state.map(...) (new array with the item copied); adding → [...state, new]; removing → state.filter(...). Forbidden: state.push(...), item.qty++, state[0].qty = 2. All of that mutates the old cart, leaves the same reference, and React doesn't see the change (you verified it with before === after → false). Module 3's immutability wasn't a recommendation: in a reducer it's the law, and it's what links purity with React re-rendering.
The reducer is pure; that's why we could execute it. This whole module keeps the promise of "execute, don't quote". The App's JSX with useReducer is shown (it can't run without React), but the cartReducer is a pure function —it receives state and action, returns state, without effects or hidden memory—, and a pure function is tested with an input and an output, without a browser. That's why the cart is the piece we really execute: it's the app's logic in its most verifiable form. Pulling the logic out into a pure reducer not only tidies it; it makes it testable, which is one of the best reasons to use reducers, beyond React.
Common mistakes
Keeping the cart in a sibling instead of in the App. What happens: you put the cart's useReducer (or a useState) inside the ProductList (where the "Add" buttons are), and then the Cart —its sibling— can't see it. Why it happens: it seems natural for the state to live "where it's changed". How to spot it: you add products and the ProductList reacts, but the Cart stays empty; the two show different things. How to fix it: lift the cart to the common ancestor (App) and pass it down by props. The state lives where several share it, not where one changes it. It's lesson 2 of module 7.
Mutating the state in the reducer. What happens: you write state.push(newLine) in add, or line.qty++ in the raise-quantity branch, and you return state. The UI doesn't update. Why it happens: it's the "natural" JavaScript way, and it's forgotten that React compares references. How to spot it: the reducer runs (you see it with a log) but the screen doesn't change, because you returned the same reference that came in. How to fix it: never mutate; return new structures with map, filter, spread. Mechanical rule: if in your reducer there appears push, pop, splice, sort on the state, or an = that assigns to a property of the state, it's wrong.
Putting the logic in the handler instead of in the reducer. What happens: you use useReducer, but in the onClick you compute the new cart by hand and dispatch { type: 'set', cart: newCart }. The reducer ends up as a shell and you lost the advantage. Why it happens: the habit of computing the new value before "setting it", like with useState. How to spot it: your actions carry the already-computed state ({ type: 'set', ... }) instead of describing what happened ({ type: 'add', product }). How to fix it: the action describes what happened (add with the product); the reducer computes the new state. The handler only dispatches; it doesn't cook. (In lesson 6 we connect them this way.)
Exercises
Exercise 1 — Predict the sequence. With the example's cartReducer, starting from an empty cart, apply these actions in order and write the state and the total after each: (1) add HUB; (2) add HUB; (3) add MOUSE; (4) remove HUB.
See solution
- (1) add HUB: the hub wasn't there → new line
USB-C Hub x1. Total$34.99(3499). - (2) add HUB: was already there → goes up to
x2. Total$69.98(3499×2). - (3) add MOUSE: new → second line. State
[USB-C Hub x2, Wireless Mouse x1]. Total$95.97(6998 + 2599). - (4) remove HUB: had
qty 2→ goes down tox1(not removed). State[USB-C Hub x1, Wireless Mouse x1]. Total$60.98(3499 + 2599).
The key: add on something already there raises quantity, and remove on something with qty > 1 lowers quantity (it only removes the line when the quantity would reach 0).
Exercise 2 — Add a setQty action. Design an action { type: 'setQty', id, qty } that sets a line's quantity to an exact number (for example, the user types "3" in a quantity input). Write the case 'setQty' without mutating, and decide what happens if qty is 0.
See solution
case 'setQty': {
if (action.qty <= 0) {
// setting to 0 (or less) is equivalent to removing the line
return state.filter((item) => item.id !== action.id);
}
return state.map((item) =>
item.id === action.id ? { ...item, qty: action.qty } : item
);
}
- If
qty <= 0, the line is removed (filter → new array): a quantity of 0 isn't a line withqty 0, it's not having the line. It keeps the state clean. - If
qty > 0, it's set withmap, copying the changed item ({ ...item, qty: action.qty }) and leaving the rest intact. New array, without mutating.
setQty doesn't need to know the previous quantity: it sets it, doesn't increment it. The action brings everything the reducer needs (id and qty).
Exercise 3 — Find the mutation. This attempt at remove is wrong: on removing, the UI sometimes doesn't update. Find why and fix it without mutating.
case 'remove': {
const i = state.findIndex((item) => item.id === action.id);
if (state[i].qty > 1) {
state[i].qty--; // (?)
return state; // (?)
}
state.splice(i, 1); // (?)
return state; // (?)
}
See solution
There are two mutations, and both return the same state reference, so React doesn't see the change:
state[i].qty--mutates the existing line's object (which lives insidestate).state.splice(i, 1)mutates thestatearray (removes an element in place).
In both cases return state returns the same array that came in: newState === oldState, and React doesn't re-render. The fix is to return new structures:
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);
}
Now map (with the item copied) and filter create new arrays, without touching the old state. New reference → React sees the change → re-render. It's the same remove from the worked example.
Summary and next step
In this lesson you gave the cart life with module 7's two decisions. Where it lives: the cart lifted in the App (the common ancestor of the ProductList that adds and the Cart that shows), a single source of truth that comes down by props. How it changes: the cartReducer, a pure function (state, action) => newState with add (raises quantity or adds a line), remove (lowers quantity or removes) and clear, connected to the App with useReducer. You anchored it with the cashier with their rulebook (current ticket + operation → new ticket, without improvising). And you executed it: a complete sequence where the state and the total in cents added up at every step, with the purity proof (before === after → false).
Before moving on you should be able to: lift a shared state to the common ancestor; write a reducer with switch (action.type), each branch returning new structures and a default: return state; connect it with useReducer and pass the callbacks down to the children; and explain why purity is what lets React see the change.
Lesson 6 wires the cables: until now the search (lesson 4) and the cart (this one) live, but separately. Now we join them in an App that handles both —query with useState, cart with useReducer— and we connect the callbacks that go up (M4): onSearch that changes the query, onAddToCart/onRemoveFromCart that dispatch to the reducer. You'll execute a complete session where you'll see that the search and the cart are independent threads —searching doesn't touch the cart, adding doesn't touch the search— and that the two siblings always agree because they read a single source. The storefront starts to behave like a real app.
Resources
- React, "Extracting State Logic into a Reducer" — react.dev/learn/extracting-state-logic-into-a-reducer. The official reducers page: the
(state, action) => newStatesignature, the actions, and why to consolidate the logic there. The core of this lesson. In English. - React, "Sharing State Between Components" — react.dev/learn/sharing-state-between-components. Why the cart shared by two siblings goes up to the common ancestor (
App) and comes down by props: lifting the state. In English. - React, "Updating Arrays in State" — react.dev/learn/updating-arrays-in-state. How to add, remove and change items of an array without mutating (
map,filter, spread) —thecartReducer's operations—. In English. - React, "useReducer" (API reference) — react.dev/reference/react/useReducer. The hook's exact signature,
dispatch, the initial state; the API you write in theApp. In English.