Project: Shopping Cart
A shopping cart is the quintessential React project for learning global state. It forces you to share data across unrelated components — a product grid, a header badge, and a slide-out drawer — without threading props through every layer. In this guide you’ll build a complete cart with add, remove, and quantity controls, derived totals, and a checkout summary. Along the way you’ll see when the built-in Context API is enough and when a library like Zustand earns its place.
What you’ll build
The app has three views that all read and write the same cart:
- A product grid with “Add to cart” buttons.
- A header badge showing the total item count.
- A cart drawer with quantity steppers, line removal, and a checkout total.
Scaffold a project with Vite and start the dev server:
npm create vite@latest shopping-cart -- --template react
cd shopping-cart
npm install
npm run dev
Modeling the data
Keep the product catalog separate from cart state. Products are static reference data; the cart is a list of line items, each pointing to a product id and a quantity. Storing only id and quantity (rather than the whole product object) keeps the cart small and avoids stale copies if a price changes.
// src/data/products.js
export const products = [
{ id: "p1", name: "Mechanical Keyboard", price: 89.0 },
{ id: "p2", name: "USB-C Hub", price: 34.5 },
{ id: "p3", name: "27\" Monitor", price: 249.99 },
{ id: "p4", name: "Webcam 1080p", price: 59.0 },
];
Cart state with Context and useReducer
A reducer is ideal here because cart mutations are well-defined actions: add, remove, change quantity, clear. Pairing useReducer with Context exposes both the state and a dispatch to any descendant.
// src/cart/CartContext.jsx
import { createContext, useContext, useReducer, useMemo } from "react";
const CartContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case "add": {
const existing = state.find((i) => i.id === action.id);
if (existing) {
return state.map((i) =>
i.id === action.id ? { ...i, quantity: i.quantity + 1 } : i
);
}
return [...state, { id: action.id, quantity: 1 }];
}
case "setQuantity": {
if (action.quantity <= 0) {
return state.filter((i) => i.id !== action.id);
}
return state.map((i) =>
i.id === action.id ? { ...i, quantity: action.quantity } : i
);
}
case "remove":
return state.filter((i) => i.id !== action.id);
case "clear":
return [];
default:
return state;
}
}
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const value = useMemo(() => ({ items, dispatch }), [items]);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within a CartProvider");
return ctx;
}
Wrap the context value in
useMemoso consumers don’t re-render on every parent render. Without it, a new object identity is created each pass and Context defeats your memoization.
Computing totals
Totals are derived state — never store them. Compute them from items during render so they stay in sync automatically. A small selector hook keeps the math in one place.
// src/cart/useCartTotals.js
import { useMemo } from "react";
import { products } from "../data/products";
import { useCart } from "./CartContext";
export function useCartTotals() {
const { items } = useCart();
return useMemo(() => {
const detailed = items.map((item) => {
const product = products.find((p) => p.id === item.id);
return { ...product, quantity: item.quantity };
});
const count = detailed.reduce((sum, i) => sum + i.quantity, 0);
const subtotal = detailed.reduce((sum, i) => sum + i.price * i.quantity, 0);
return { detailed, count, subtotal };
}, [items]);
}
Building the UI
The product grid dispatches add, the header reads count, and the drawer renders detailed lines with steppers.
// src/components/ProductGrid.jsx
import { products } from "../data/products";
import { useCart } from "../cart/CartContext";
export function ProductGrid() {
const { dispatch } = useCart();
return (
<div className="grid">
{products.map((p) => (
<article key={p.id} className="card">
<h3>{p.name}</h3>
<p>${p.price.toFixed(2)}</p>
<button onClick={() => dispatch({ type: "add", id: p.id })}>
Add to cart
</button>
</article>
))}
</div>
);
}
// src/components/CartDrawer.jsx
import { useCart } from "../cart/CartContext";
import { useCartTotals } from "../cart/useCartTotals";
export function CartDrawer() {
const { dispatch } = useCart();
const { detailed, count, subtotal } = useCartTotals();
if (count === 0) return <aside className="drawer">Your cart is empty.</aside>;
return (
<aside className="drawer">
<h2>Cart ({count})</h2>
<ul>
{detailed.map((line) => (
<li key={line.id}>
<span>{line.name}</span>
<input
type="number"
min="0"
value={line.quantity}
onChange={(e) =>
dispatch({
type: "setQuantity",
id: line.id,
quantity: Number(e.target.value),
})
}
/>
<span>${(line.price * line.quantity).toFixed(2)}</span>
<button onClick={() => dispatch({ type: "remove", id: line.id })}>
Remove
</button>
</li>
))}
</ul>
<strong>Subtotal: ${subtotal.toFixed(2)}</strong>
<button onClick={() => dispatch({ type: "clear" })}>Checkout</button>
</aside>
);
}
Mount everything under one provider so the grid, header, and drawer share state:
// src/App.jsx
import { CartProvider } from "./cart/CartContext";
import { ProductGrid } from "./components/ProductGrid";
import { CartDrawer } from "./components/CartDrawer";
export default function App() {
return (
<CartProvider>
<main>
<ProductGrid />
<CartDrawer />
</main>
</CartProvider>
);
}
Adding two keyboards and one hub yields:
Output:
Cart (3)
Mechanical Keyboard qty 2 $178.00
USB-C Hub qty 1 $34.50
Subtotal: $212.50
Context vs. Zustand
Context is perfect for this scope. As state grows — persistence, async pricing, frequent updates from many components — a dedicated store reduces boilerplate and re-renders.
| Concern | Context + useReducer | Zustand |
|---|---|---|
| Setup | Built in, zero deps | One small dependency |
| Re-render control | Whole tree on value change | Per-selector subscriptions |
| Boilerplate | Provider + reducer + hook | Single create call |
| Best for | Small/medium shared state | Large or high-frequency state |
For Zustand the entire store collapses to a few lines, and components subscribe only to the slices they read:
// src/cart/store.js
import { create } from "zustand";
export const useCartStore = create((set) => ({
items: [],
add: (id) =>
set((s) => {
const existing = s.items.find((i) => i.id === id);
return {
items: existing
? s.items.map((i) =>
i.id === id ? { ...i, quantity: i.quantity + 1 } : i
)
: [...s.items, { id, quantity: 1 }],
};
}),
clear: () => set({ items: [] }),
}));
Best Practices
- Store only
idandquantityin the cart; resolve product details at render time so prices never go stale. - Treat totals and line subtotals as derived state — compute them, don’t persist them.
- Memoize the Context value to prevent needless re-renders of every consumer.
- Model mutations as explicit reducer actions; it makes the cart predictable and easy to test.
- Clamp quantity at zero and remove the line, so the UI can never show negative counts.
- Reach for Zustand or Redux Toolkit only when per-selector subscriptions or persistence become real needs — not by default.