Module 7: Lifting State And Composition

Mini-project: the storefront's shared cart

Overview

The moment has come to bring the whole module together into a single piece. Mercado's cart touches everything you learned here: it lives lifted in the App (because the ProductList that adds and the sibling Cart that shows share it), its logic is in the cartReducer managed with useReducer (because adding raises quantities, removing lowers them, clearing empties), it comes down to the children with composition (a Layout with children, to not chain props), and the view is separated into presentational components. This mini-project is the synthesis: the component tree with its data flow, the real React code of each piece, and a user session executed in Node —add, raise quantity, remove, clear— with the cart's state and total, literal.

Connection with the module. It's the capstone. It gathers the three legs: lifting (lessons 2-3), the reducer with useReducer (lessons 4-5), and composing + container/presentational (lessons 6-7). And it closes the module's boundary: here the shared state is solved with lifting + reducer + composition, without Context or global state —which, when the data is needed by half the tree, are from frontend-state-and-data—. With this you finish the module and are ready for the guide's capstone (module 8), where you build the complete storefront end to end.

The plan: what we're going to assemble

The storefront has the cart as central state, and three design rules that come out of the module:

  1. The cart lives in the App (the common ancestor of ProductList and Cart), not in any sibling. A single source of truth (lesson 2).
  2. Its logic is in the cartReducer, managed with useReducer in the App. The handlers only dispatch actions (add, remove, clear); the reducer decides (lessons 4-5).
  3. It comes down with composition and a separated view. A Layout with children avoids prop drilling (lesson 6); the Cart/CartView is presentational (only shows its props), and the App is the container (has the state) (lesson 7).

The App is the owner of the cart and of the dispatch. The children only notify by dispatching; the App, through the reducer, decides.

The tree and the data flow

This is the storefront with the cart lifted in the App, coming down via props (solid) and the actions going up via callbacks/dispatch (dotted):

flowchart TD
    App["App  (useReducer: cart, dispatch)"]
    Layout["Layout  (children: unaware of cart)"]
    PL[ProductList]
    Cart[Cart / CartView]
    PC1[ProductCard]
    PC2[ProductCard]
    CI[CartItem]

    App -- "children" --> Layout
    App -- "products, onAddToCart" --> PL
    App -- "items, onRemoveFromCart, onClear" --> Cart
    PL --> PC1
    PL --> PC2
    Cart --> CI

    PC1 -. "dispatch(add, product)" .-> App
    CI -. "dispatch(remove, id)" .-> App

Read it in both directions. Downward (solid): the App passes down to the ProductList the way to add (onAddToCart), and to the Cart the items to show plus the ways to remove and clear (onRemoveFromCart, onClear). The Layout receives children —it doesn't know the cart—, wrapping the view without carrying foreign props (composition, lesson 6). Upward (dotted): the ProductCard and the CartItem dispatch actions that go up to the App's reducer. All the state lives in a single place (the App, container); the others show it or request changes (presentational). That's the whole module, drawn.

The real React code

This is the storefront as you'd write it in React. Read it piece by piece, noticing where each lesson lands.

App — container: owner of the lifted cart and the reducer.

function App() {
  // The cart lives HERE (common ancestor), managed by the cartReducer.
  const [cart, dispatch] = useReducer(cartReducer, []);

  return (
    <Layout>
      {/* the ProductList gets the way to ADD */}
      <ProductList
        products={PRODUCTS}
        onAddToCart={(product) => dispatch({ type: 'add', product })}
      />
      {/* the sibling Cart gets the ITEMS and the ways to remove/clear */}
      <Cart
        items={cart}
        onRemoveFromCart={(id) => dispatch({ type: 'remove', id })}
        onClear={() => dispatch({ type: 'clear' })}
      />
    </Layout>
  );
}

The App has the state (useReducer) and passes down to each child just enough (lesson 2). The handlers are one line: they dispatch a ticket, they don't compute the cart (lesson 5). And the App doesn't drill props through the Layout: it passes the content as children (lesson 6).

Layout — generic wrapper with children (composition).

function Layout({ children }) {
  // It doesn't know the cart: it only paints what's put in its hole.
  return (
    <div className="app-shell">
      <header><h1>Mercado</h1></header>
      <main>{children}</main>
    </div>
  );
}

The Layout receives children and places it; it doesn't mention cart nor dispatch. It's the picture frame: it serves to wrap anything, and the cart doesn't cross through it (lesson 6).

ProductList and ProductCard — dispatch add.

function ProductList({ products, onAddToCart }) {
  return (
    <section className="product-list">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} onAddToCart={onAddToCart} />
      ))}
    </section>
  );
}

function ProductCard({ product, onAddToCart }) {
  return (
    <article className="product-card">
      <h3>{product.name}</h3>
      <p>{formatPrice(product.priceCents)}</p>
      {/* sends the action up: the arrow defers the call until the click (module 4) */}
      <button onClick={() => onAddToCart(product)}>Add to cart</button>
    </article>
  );
}

ProductList forwards onAddToCart to each ProductCard (legitimate forwarding to children it renders, lesson 6). The ProductCard is presentational: it shows the product and notifies via the callback; it doesn't touch the cart (lesson 7).

Cart and CartItem — presentational: show and dispatch remove/clear.

function Cart({ items, onRemoveFromCart, onClear }) {
  const total = items.reduce((sum, i) => sum + i.priceCents * i.qty, 0);
  return (
    <section className="cart">
      <h2>Cart ({items.length})</h2>
      <ul>
        {items.map((item) => (
          <CartItem key={item.id} item={item} onRemove={onRemoveFromCart} />
        ))}
      </ul>
      <p>TOTAL: {formatPrice(total)}</p>
      <button onClick={onClear}>Clear cart</button>
    </section>
  );
}

function CartItem({ item, onRemove }) {
  return (
    <li>
      {item.name} x{item.qty} — {formatPrice(item.priceCents * item.qty)}
      <button onClick={() => onRemove(item.id)}>Remove</button>
    </li>
  );
}

The Cart is a pure function of its props: it receives items and the strings (onRemoveFromCart, onClear), and only shows —the total it derives from the items, it doesn't store it (module 5)—. It has no useReducer nor knows where the items come from (lesson 7). The CartItem notifies via onRemove(item.id); the App decides to dispatch remove.

Worked example: a user session, executed

We're going to run the storefront simulating a real session: the user adds the mouse, adds the keyboard, adds the mouse again (raises its quantity to 2), removes the keyboard, adds the hub, and finally clears the cart. We model the App with its lifted cart and its dispatch, and on each action we "render" describing what both siblings see —the ProductList (always the 3 products) and the Cart (the items and the total)—.

// ---- the PURE reducer: (state, action) => newState (lesson 4) ----
function cartReducer(state, action) {
  switch (action.type) {
    case 'add': {
      const line = state.find((item) => item.id === action.product.id);
      if (line) {
        return state.map((item) =>
          item.id === action.product.id ? { ...item, qty: item.qty + 1 } : item);
      }
      return [...state, { ...action.product, qty: 1 }];
    }
    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);
    }
    case 'clear': return [];
    default: return state;
  }
}
function cartTotal(state) { return state.reduce((s, i) => s + i.priceCents * i.qty, 0); }
function formatPrice(cents) { return '$' + (cents / 100).toFixed(2); }

// ---- App: owner of the lifted cart + a dispatch that comes down via props ----
const PRODUCTS = [
  { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599 },
  { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
  { id: 'p3', name: 'USB-C Hub',           priceCents: 3499 },
];
let cart = [];
function dispatch(action) { cart = cartReducer(cart, action); render(action); }

// ---- "render": describes the UI for the current state (both siblings) ----
function render(lastAction) {
  const count = cart.reduce((s, i) => s + i.qty, 0);
  const lines = cart.map((i) => `${i.name} x${i.qty}`).join(', ');
  const tag = lastAction ? lastAction.type.toUpperCase().padEnd(7) : 'INITIAL';
  console.log(`${tag} | ProductList: 3 products | Cart(${count}): [${lines}] total ${formatPrice(cartTotal(cart))}`);
}

// ---- a user session ----
console.log('=== Storefront: lifted cart + cartReducer + composition ===\n');
render(null);
dispatch({ type: 'add', product: PRODUCTS[0] }); // ProductList: adds Mouse
dispatch({ type: 'add', product: PRODUCTS[1] }); // ProductList: adds Keyboard
dispatch({ type: 'add', product: PRODUCTS[0] }); // ProductList: adds Mouse again (qty 2)
dispatch({ type: 'remove', id: PRODUCTS[1].id }); // Cart: removes the Keyboard
dispatch({ type: 'add', product: PRODUCTS[2] }); // ProductList: adds Hub
dispatch({ type: 'clear' });                      // Cart: clears everything

console.log(`\nFinal cart: [${cart.map((i) => i.name + ' x' + i.qty).join(', ')}] (empty)`);

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

=== Storefront: lifted cart + cartReducer + composition ===

INITIAL | ProductList: 3 products | Cart(0): [] total $0.00
ADD     | ProductList: 3 products | Cart(1): [Wireless Mouse x1] total $25.99
ADD     | ProductList: 3 products | Cart(2): [Wireless Mouse x1, Mechanical Keyboard x1] total $114.99
ADD     | ProductList: 3 products | Cart(3): [Wireless Mouse x2, Mechanical Keyboard x1] total $140.98
REMOVE  | ProductList: 3 products | Cart(2): [Wireless Mouse x2] total $51.98
ADD     | ProductList: 3 products | Cart(3): [Wireless Mouse x2, USB-C Hub x1] total $86.97
CLEAR   | ProductList: 3 products | Cart(0): [] total $0.00

Final cart: [] (empty)

Read the session from start to finish, because in it is the whole module working together.

The initial state shows the ProductList with its 3 products and the Cart empty (Cart(0), total $0.00). The two siblings start from the same truth, because they read the same cart from the App.

Then the user adds the mouse from the ProductList: { type: 'add', product: MOUSE } is dispatched, the App's reducer computes the new cart, and the Cart —the sibling— shows it instantly: Cart(1): [Wireless Mouse x1] total $25.99. Adds the keyboard: Cart(2), total $114.99 (2599 + 8900). Adds the mouse again: here the reducer's logic shows —instead of duplicating the line, it raises the quantity to x2—, and the count goes to Cart(3) (three units, two lines), total $140.98 (2599×2 + 8900).

The user removes the keyboard from the Cart: the other sibling fires { type: 'remove', id }, the reducer removes that line (it had qty 1), and Cart(2): [Wireless Mouse x2] remains, total $51.98. Adds the hub: third line, Cart(3), total $86.97 (5198 + 3499). And finally clears: clear leaves the cart at Cart(0), total $0.00. The Final cart confirms it was left empty.

Stop at the essential thing, which is the whole module: no matter who added and who removed, there was a single cart in the App, and the two siblings always showed the same. The ProductList fired add, the Cart fired remove and clear, but neither stored its own state: they all dispatched actions to the container's reducer, which decided, and the view followed. Lifting (a single cart), the reducer (the add/remove/clear logic with quantities), and the container/presentational separation (the App decides, the Cart shows), in a single executed session.

Common mistakes

Storing the cart in a sibling instead of in the App. What happens: you put the cart's useReducer inside the ProductList or the Cart, and the other sibling doesn't see it; they desync (lesson 1). Why it happens: it seems natural for the state to live "where it's changed" or "where it's shown". How to spot it: you add and the Cart doesn't react, or you remove and the count doesn't drop on the other side. How to fix it: the cart lives in the common ancestor (the App), and comes down via props to the two siblings. It's the module's central rule.

Putting the cart logic in the handlers instead of the reducer. What happens: the App uses useReducer but the handlers compute the cart by hand and dispatch { type: 'set', cart }; the reducer is left a shell. Why it happens: the habit of computing the new value before setting it. How to spot it: your actions carry already-computed state instead of describing what happened. How to fix it: the actions describe the interaction (add, remove, clear) and the reducer computes; the handlers only dispatch (lesson 5).

Drilling props through the Layout (or giving the Cart state). What happens: you pass cart/dispatch through the Layout that doesn't use them (prop drilling), or the Cart copies the items to its own useState and desyncs. Why it happens: passing everything down via props "out of habit", or storing in state what arrives. How to spot it: the Layout receives props it doesn't touch, or the Cart doesn't reflect the cart's changes. How to fix it: pass the Layout children (composition, lesson 6); the Cart shows its props directly, without copying them to state (presentational, lesson 7). The total is derived from the items, not stored (module 5).

Exercises

Exercise 1 — Trace a new session. With the worked example's model (same PRODUCTS), the user: adds the hub, adds the hub again, adds the mouse, and removes the hub once. Write the final render line: the cart count (Cart(N)), the lines, and the total.

See solution

Step by step:

  • add HUB: new → [USB-C Hub x1]. Cart(1), total $34.99 (3499).
  • add HUB: already there → raises to x2. Cart(2), total $69.98 (3499×2).
  • add MOUSE: new → [USB-C Hub x2, Wireless Mouse x1]. Cart(3), total $95.97 (6998 + 2599).
  • remove HUB: had qty 2 → lowers to x1 (not removed). [USB-C Hub x1, Wireless Mouse x1]. Cart(2), total $60.98 (3499 + 2599).

The final line:

REMOVE  | ProductList: 3 products | Cart(2): [USB-C Hub x1, Wireless Mouse x1] total $60.98

The key: add on something existing raises the quantity, and remove on something with qty > 1 lowers the quantity (it doesn't delete the line). The Cart(N) count sums quantities, not lines.

Exercise 2 — Add "clear" and a badge. The header must show a CartBadge with how many items there are (sum of quantities). (a) Where does that data live and how does it reach the CartBadge? (b) Write the CartBadge as a presentational component. (c) Why doesn't it need its own useReducer?

See solution

(a) The count is derived from the cart, which already lives lifted in the App. The App computes it (or passes the cart) and the CartBadge receives the number via props. If the CartBadge is in the Header inside the Layout, the App can build it and pass it as part of the children/a slot, to not drill props (composition).

(b) Presentational, a pure function of props:

function CartBadge({ count }) {
  return <span className="badge">{count}</span>;
}
// the App builds it with the derived data:
// const count = cart.reduce((s, i) => s + i.qty, 0);
// <CartBadge count={count} />

(c) It doesn't need useReducer (nor any state) because it owns no data: it only shows a number that reaches it via props. The cart already lives in the App (a single source of truth); duplicating it in the CartBadge would desync it (lesson 3). The CartBadge is a puppet: same props → same look.

Exercise 3 — Which lesson solves what? This project's storefront touches the whole module. For each piece, say which lesson the underlying concept is from: (a) the cart living in the App and not in a sibling; (b) dispatch({ type: 'add', product }) in the handler; (c) the Layout receiving children without knowing the cart; (d) the Cart being a pure function of its props.

See solution
  • (a) The cart in the Applifting the state (lessons 2-3): the shared state lives in the common ancestor, not in a sibling.
  • (b) dispatch({ type: 'add', product })useReducer and the reducer (lessons 4-5): the handler dispatches an action; the cartReducer (pure function) decides the new state.
  • (c) The Layout with childrencomposition over prop drilling (lesson 6): the cart doesn't cross the Layout; it's built where it lives and travels as children.
  • (d) The Cart as a pure function of propscontainer vs presentational (lesson 7): the Cart only shows its props; the state and the logic live in the App (container).

The project integrates the module's three legs —lifting, reducer, composing— in the storefront. And none needed Context or global state: for this case, lifting + composition are enough (the boundary with frontend-state-and-data).

Summary and next step

In this mini-project you assembled the storefront's shared cart end to end and saw the whole module work together. The cart lives lifted in the App (a single source of truth for the two siblings), its logic is in the cartReducer managed with useReducer (the handlers only dispatch add/remove/clear; the reducer decides), it comes down with composition (a Layout with children, without prop drilling), and the view is separated into presentational (the Cart, a pure function of its props; the App, a container with the state). You measured it in a complete user session —add, raise quantity, remove, clear— with the cart's state and total ($140.98, $86.97, and back to $0.00), and at no point were there two versions of the cart: no matter who added or removed, the two siblings showed the same, because they read the same cart from the container.

Before closing the module you should be able to: place the shared state in the common ancestor; manage it with a reducer and dispatch actions from the handlers; compose with children to not chain props; separate container (state) from presentational (view); and distinguish what this module solves (lifting + reducer + composition) from what belongs to frontend-state-and-data (Context, global state).

And where the guide goes next. With this module you have the four big pieces of client-side React: components and JSX (modules 1-2), state and events (modules 3-4), deriving and effects (modules 5-6), and sharing state and composing (module 7). Module 8 is the capstone: you build the complete Mercado storefrontProductList with SearchBar (controlled input), derived filtering/sorting, Cart with the cartReducer (state lifted in the App), and an effect that loads the products (simulated)— integrating everything, with the component tree, the real React code, and the logic executed in Node. And after that, the Fullstack ecosystem takes you to production: nextjs-app-router (SSR/routing), frontend-state-and-data (Context, global state, server data), ui-systems-and-design-implementation (styles), and fullstack-performance-and-deployment (performance and deploy).

Resources