Skip to content
React projects 5 min read

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 useMemo so 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.

ConcernContext + useReducerZustand
SetupBuilt in, zero depsOne small dependency
Re-render controlWhole tree on value changePer-selector subscriptions
BoilerplateProvider + reducer + hookSingle create call
Best forSmall/medium shared stateLarge 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 id and quantity in 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.
Last updated June 14, 2026
Was this helpful?