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
cartReducerfunction handlingADD_ITEM,REMOVE_ITEM, andUPDATE_QUANTITYactions. - A
CartContextcreated withcreateContextand aCartProvidercomponent wrapping the app. - A custom
useCart()hook that reads the context safely, with a clear error if used in the wrong place. - A
ProductListcomponent that dispatchesADD_ITEMwithout needing any cart props passed in. - A
CartSummarycomponent 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).
useStatefundamentals —useReducerbuilds directly on the same "state + a function to update it" idea.- Array methods —
find,map,filter, andreduce, used throughout the reducer and the cart total calculation. - Immutable updates — spreading objects and arrays with
...instead of mutating them in place. useReducerand 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
CartSummarythat dispatches aCLEAR_CARTaction and shows a thank-you message. - Persist
state.itemstolocalStorageinsideCartProviderwith auseEffect, the same pattern used in the Todo App project. - Add quantity stepper
+/-buttons instead of a raw number input, dispatchingUPDATE_QUANTITYwithitem.quantity + 1or- 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 calladdItem(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.