LearnAI ToolsCareerPractice BuildsPlayContact
React JSIntermediate~2 hours

Shopping Cart

Build a cart with shared state across components using Context API.

Context APIuseReducerProps

Overview

A shopping cart is the standard project for learning the Context API because it has a real, unavoidable state-sharing problem: a `ProductList` component needs to add items to the cart, and a completely separate `CartSummary` component needs to display and update those same items — with neither one a parent of the other. Passing the cart down through props from a common ancestor would mean threading it through components that do not actually use it themselves, a pattern known as prop drilling.

Instead, this project manages the cart's state with `useReducer` — a more structured alternative to `useState` for state that changes through a small, well-defined set of actions — and exposes it through `createContext`, so `ProductList` and `CartSummary` can each reach into the cart directly, no matter how deep they are rendered.

What You'll Build
  • A `cartReducer` function handling `ADD_ITEM`, `REMOVE_ITEM`, and `UPDATE_QUANTITY` actions.
  • A `CartContext` created with `createContext` and a `CartProvider` component wrapping the app.
  • A custom `useCart()` hook that reads the context safely, with a clear error if used in the wrong place.
  • A `ProductList` component that dispatches `ADD_ITEM` without needing any cart props passed in.
  • A `CartSummary` component that reads live cart totals and dispatches quantity/removal changes.
  • Two sibling components sharing one source of truth with zero prop drilling between them.

Prerequisites

  • JSX basics and function components — you should be comfortable with props from a first React project (like the Todo App above).
  • `useState` fundamentals — `useReducer` builds directly on the same "state + a function to update it" idea.
  • Array methods — `find`, `map`, `filter`, and `reduce`, used throughout the reducer and the cart total calculation.
  • Immutable updates — spreading objects and arrays with `...` instead of mutating them in place.
  • `useReducer` and the Context API are new here — both are introduced from the ground up in this tutorial.

Project Structure

A `CartContext` and `CartProvider` live in their own file so any component in the tree can import and use them. `ProductList` and `CartSummary` are true siblings — both rendered directly inside `App`, next to each other — which is exactly the layout that would force prop drilling without Context.

src/
CartContext.jsx // createContext, cartReducer, CartProvider, and the useCart() hook
products.js // Static list of products available to add to the cart
ProductList.jsx // Reads nothing from the cart, only dispatches ADD_ITEM
CartSummary.jsx // Reads cart items + dispatches REMOVE_ITEM / UPDATE_QUANTITY
App.jsx // Wraps ProductList and CartSummary in <CartProvider>
index.css // Shared dark-theme styling
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.shop-app { width: 100%; max-width: 480px; background: #23262e; border-radius: 10px; padding: 24px; }
.shop-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
.shop-app h2 { margin: 16px 0 10px; font-size: 1.1rem; }
.product-row, .cart-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #3a3f4b;
}
.product-row span:first-child, .cart-row span:first-child { flex: 1; }
.product-row button, .cart-row button {
padding: 6px 12px;
border: none;
border-radius: 6px;
background: #4f8cff;
color: white;
cursor: pointer;
font-size: 0.85rem;
}
.cart-row button { background: #5a3a3a; }
.cart-row input[type="number"] {
width: 48px;
padding: 4px;
border-radius: 4px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
text-align: center;
}
.cart-total { margin-top: 12px; font-weight: bold; text-align: right; font-size: 1.1rem; }
.cart-empty { color: #888; padding: 8px 0; }

Step 1: Define Product Data and Cart State Shape

The product catalog is static data, not state — it never changes while the app runs, so it lives as a plain exported array rather than in `useState`. The cart itself is designed as `{ items: [...] }`, where each cart item is a product's fields plus a `quantity` — kept as a flat array so the reducer can work with ordinary array methods.

// products.js
export const PRODUCTS = [
{ id: 1, name: 'Wireless Mouse', price: 24.99 },
{ id: 2, name: 'Mechanical Keyboard', price: 89.99 },
{ id: 3, name: 'USB-C Hub', price: 34.5 },
{ id: 4, name: 'Laptop Stand', price: 42.0 },
];
// Cart state shape, used throughout CartContext.jsx:
// { items: [{ id, name, price, quantity }] }
//
// Deliberately a flat array (not an object keyed by id) so the reducer below
// can lean on plain array methods (find/map/filter) instead of juggling
// object-key bookkeeping

Step 2: Write the Cart Reducer

A reducer is a function that takes the current state and an "action" describing what happened, and returns the *next* state — it never mutates the state it was given. `useReducer` is a better fit than `useState` here because the cart has several distinct kinds of updates (add, remove, change quantity), and a `switch` on `action.type` keeps all of that update logic in one predictable, testable place instead of scattered across components.

// CartContext.jsx (part 1 of 2)
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find((item) => item.id === action.product.id);
if (existing) {
// Already in the cart — bump its quantity instead of adding a
// duplicate row. map() keeps every OTHER item's object reference
// exactly the same, since only the matching one actually changed
return {
items: state.items.map((item) =>
item.id === action.product.id ? { ...item, quantity: item.quantity + 1 } : item
),
};
}
// First time this product is added — spread its fields and start quantity at 1
return { items: [...state.items, { ...action.product, quantity: 1 }] };
}
case 'REMOVE_ITEM':
return { items: state.items.filter((item) => item.id !== action.id) };
case 'UPDATE_QUANTITY': {
if (action.quantity < 1) {
// Treat "quantity 0 or less" as a removal rather than leaving a
// useless zero-quantity row in the cart
return { items: state.items.filter((item) => item.id !== action.id) };
}
return {
items: state.items.map((item) =>
item.id === action.id ? { ...item, quantity: action.quantity } : item
),
};
}
default:
// Throwing on an unrecognized action type surfaces a typo'd
// action.type immediately during development, instead of the cart
// silently doing nothing and leaving a bug to track down later
throw new Error('Unknown cart action type: ' + action.type);
}
}
const initialCartState = { items: [] };
How State Flows Through the Reducer

Click Run to see what this code prints.

Step 3: Create CartContext and a Provider

`createContext` makes a channel that any descendant component can read from, without it being passed down through props at every level. `CartProvider` is the component that actually holds the `useReducer` call and hands both `items` and `dispatch` to everything inside it via `<CartContext.Provider value={...}>`.

// CartContext.jsx (part 2 of 2)
import { createContext, useContext, useReducer } from 'react';
// No default value is passed to createContext (it defaults to undefined) —
// that's intentional. It lets useCart() below detect and clearly warn about
// a component that was rendered outside <CartProvider> by mistake
const CartContext = createContext(undefined);
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialCartState);
// Every descendant gets both the current items and the dispatch function.
// dispatch never changes identity between renders, so handing it down
// never causes extra re-renders on its own
return (
<CartContext.Provider value={{ items: state.items, dispatch }}>
{children}
</CartContext.Provider>
);
}
// A small custom hook wrapping useContext — every component that needs cart
// data calls useCart() instead of importing CartContext directly, and gets a
// clear error message if it's ever rendered in the wrong place
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}

Step 4: Build the Product List

`ProductList` only ever *dispatches* — it never reads `items` — so it re-renders only when its own state changes, which in this component is never, since `PRODUCTS` is static. Notice it receives zero cart-related props: `useCart()` reaches straight into context instead.

// ProductList.jsx
import { PRODUCTS } from './products';
import { useCart } from './CartContext';
function ProductList() {
const { dispatch } = useCart(); // only needs to dispatch actions, never reads items itself
return (
<div className="product-list">
<h2>Products</h2>
{PRODUCTS.map((product) => (
<div className="product-row" key={product.id}>
<span>{product.name}</span>
<span>{product.price.toFixed(2)}</span>
<button type="button" onClick={() => dispatch({ type: 'ADD_ITEM', product })}>
Add to Cart
</button>
</div>
))}
</div>
);
}
export default ProductList;

Step 5: Build the Cart Summary

`CartSummary` reads `items` straight from `useCart()` — no prop passed it that data from `App`, and no prop connects it to `ProductList` either. The running total is computed with `reduce()` on every render rather than tracked as its own state, the same "derive, don't duplicate" principle used for `filteredTasks` in the Todo App project.

// CartSummary.jsx
import { useCart } from './CartContext';
function CartSummary() {
const { items, dispatch } = useCart(); // reads items directly from context — no common-ancestor props involved
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
if (items.length === 0) {
return <p className="cart-empty">Your cart is empty.</p>;
}
return (
<div className="cart-summary">
<h2>Cart</h2>
{items.map((item) => (
<div className="cart-row" key={item.id}>
<span>{item.name}</span>
<input
type="number"
min="1"
value={item.quantity}
onChange={(event) =>
dispatch({
type: 'UPDATE_QUANTITY',
id: item.id,
quantity: Number(event.target.value), // input values are always strings; convert back to a number for the reducer
})
}
/>
<span>{(item.price * item.quantity).toFixed(2)}</span>
<button type="button" onClick={() => dispatch({ type: 'REMOVE_ITEM', id: item.id })}>
Remove
</button>
</div>
))}
<p className="cart-total">Total: {total.toFixed(2)}</p>
</div>
);
}
export default CartSummary;

Step 6: Wire It Together in App

The only thing `App` does with the cart is decide where `<CartProvider>` starts. Every component nested inside it — `ProductList` and `CartSummary` alike — automatically gets access to the same cart state, which is exactly what eliminates the prop drilling this project set out to avoid.

// App.jsx
import { CartProvider } from './CartContext';
import ProductList from './ProductList';
import CartSummary from './CartSummary';
function App() {
return (
// Wrapping BOTH ProductList and CartSummary in the SAME provider is what
// lets them share cart state without App holding or forwarding it itself
<CartProvider>
<div className="shop-app">
<h1>Shop</h1>
<ProductList />
<CartSummary />
</div>
</CartProvider>
);
}
export default App;

Complete Code

Here is the complete project — every file, fully assembled from the steps above, in the order they would sit in a real project.

// products.js
export const PRODUCTS = [
{ id: 1, name: 'Wireless Mouse', price: 24.99 },
{ id: 2, name: 'Mechanical Keyboard', price: 89.99 },
{ id: 3, name: 'USB-C Hub', price: 34.5 },
{ id: 4, name: 'Laptop Stand', price: 42.0 },
];
// CartContext.jsx
import { createContext, useContext, useReducer } from 'react';
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find((item) => item.id === action.product.id);
if (existing) {
return {
items: state.items.map((item) =>
item.id === action.product.id ? { ...item, quantity: item.quantity + 1 } : item
),
};
}
return { items: [...state.items, { ...action.product, quantity: 1 }] };
}
case 'REMOVE_ITEM':
return { items: state.items.filter((item) => item.id !== action.id) };
case 'UPDATE_QUANTITY': {
if (action.quantity < 1) {
return { items: state.items.filter((item) => item.id !== action.id) };
}
return {
items: state.items.map((item) =>
item.id === action.id ? { ...item, quantity: action.quantity } : item
),
};
}
default:
throw new Error('Unknown cart action type: ' + action.type);
}
}
const initialCartState = { items: [] };
const CartContext = createContext(undefined);
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialCartState);
return (
<CartContext.Provider value={{ items: state.items, dispatch }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
// ProductList.jsx
import { PRODUCTS } from './products';
import { useCart } from './CartContext';
function ProductList() {
const { dispatch } = useCart();
return (
<div className="product-list">
<h2>Products</h2>
{PRODUCTS.map((product) => (
<div className="product-row" key={product.id}>
<span>{product.name}</span>
<span>{product.price.toFixed(2)}</span>
<button type="button" onClick={() => dispatch({ type: 'ADD_ITEM', product })}>
Add to Cart
</button>
</div>
))}
</div>
);
}
export default ProductList;
// CartSummary.jsx
import { useCart } from './CartContext';
function CartSummary() {
const { items, dispatch } = useCart();
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
if (items.length === 0) {
return <p className="cart-empty">Your cart is empty.</p>;
}
return (
<div className="cart-summary">
<h2>Cart</h2>
{items.map((item) => (
<div className="cart-row" key={item.id}>
<span>{item.name}</span>
<input
type="number"
min="1"
value={item.quantity}
onChange={(event) =>
dispatch({ type: 'UPDATE_QUANTITY', id: item.id, quantity: Number(event.target.value) })
}
/>
<span>{(item.price * item.quantity).toFixed(2)}</span>
<button type="button" onClick={() => dispatch({ type: 'REMOVE_ITEM', id: item.id })}>
Remove
</button>
</div>
))}
<p className="cart-total">Total: {total.toFixed(2)}</p>
</div>
);
}
export default CartSummary;
// App.jsx
import { CartProvider } from './CartContext';
import ProductList from './ProductList';
import CartSummary from './CartSummary';
function App() {
return (
<CartProvider>
<div className="shop-app">
<h1>Shop</h1>
<ProductList />
<CartSummary />
</div>
</CartProvider>
);
}
export default App;
Live Preview

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Add a "Checkout" button in `CartSummary` that dispatches a `CLEAR_CART` action and shows a thank-you message.
  • Persist `state.items` to `localStorage` inside `CartProvider` with a `useEffect`, the same pattern used in the Todo App project.
  • Add quantity stepper `+`/`-` buttons instead of a raw number input, dispatching `UPDATE_QUANTITY` with `item.quantity + 1` or `- 1`.
  • Show a live item-count badge (total quantity across all items) in a navbar component that also consumes `useCart()`.
  • Split the reducer into smaller action creator functions (e.g. `addItem(product)`) so components call `addItem(product)` instead of building action objects by hand.

Summary

You built a shopping cart where two sibling components — `ProductList` and `CartSummary` — share one piece of state through the Context API, with `useReducer` keeping every update to that state funneled through a single, predictable function. This combination — reducer for the "how state changes" logic, Context for "who can reach it" — is the standard shape of shared state in small-to-medium React apps that don't need a full external state-management library.