LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
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.