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.
- 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.jsexport 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 bookkeepingStep 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: [] };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 mistakeconst 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 placeexport 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.jsximport { 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.jsximport { 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.jsximport { 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.jsexport 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.jsximport { 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.jsximport { 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.jsximport { 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.jsximport { 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;Sample Run
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.