Building an E-Commerce Application: Catalog, Cart, Auth, and Checkout

Model a product catalog, build a reducer-based cart/wishlist architecture, implement a protected-route pattern tied to authentication state, and assemble an accessible, performant checkout flow.

An e-commerce app is where a lot of 'toy app' React patterns stop working. Product data needs to be filtered, searched, and sorted without re-fetching on every keystroke. Cart state has to survive navigation, be updated from many unrelated components, and stay consistent under fast repeated clicks (add, add, remove). Some routes must only be reachable once a user is authenticated. And every one of these features has to remain usable with a keyboard and a screen reader, and fast on a phone over a slow connection loading dozens of product images. This lesson builds the architectural backbone — the data model, the cart/wishlist reducer, the protected-route guard, and the checkout flow — that the rest of the store's features hang off of.

Modeling the product catalog

The catalog data model needs to support three independent operations — search (by text), filter (by category, price range, in-stock), and sort (by price, rating, newest) — and they need to compose: a user can search AND filter AND sort at the same time. The cleanest architecture keeps the raw product list untouched and derives the displayed list through a pure pipeline function, so each concern (search, filter, sort) is one small, independently testable function.

Deriving the visible list with useMemo keyed on the raw products plus the current query/filters/sort avoids recomputing the pipeline on every render — only when an input actually changes. This also keeps the expensive work (string matching over potentially hundreds of products) off the critical path for unrelated re-renders, like the cart badge updating.

Cart and wishlist as a reducer, not scattered setState

Cart state is touched from many places: a product card's 'Add to cart' button, the cart drawer's quantity steppers, a 'Move to wishlist' action, and checkout's final clear. Once more than two or three call sites mutate the same piece of state in different ways, a reducer is the right tool — it centralizes every valid transition into one switch statement, makes invalid states hard to represent, and is trivial to unit test in isolation from any component.

Expose the reducer through a context so any component in the tree can dispatch actions and read cart/wishlist state without prop drilling through the layout. Keep the context value itself memoized (or split into separate state/dispatch contexts) so that dispatching doesn't force every consumer to re-render for unrelated reasons.

Protected routes tied to authentication state

Checkout, order history, and account settings should only be reachable when a user is logged in. The standard pattern is a wrapper component that reads auth state from context, and either renders its children (authenticated), redirects to login while remembering where the user was headed (unauthenticated), or renders a loading state (auth status not yet resolved, e.g. verifying a stored token on app boot).

That third state — 'not yet known' — is the one naive implementations skip, and it causes a very visible bug: on page refresh, an authenticated user gets bounced to the login page for a flash because the route guard checked auth state before the token verification request had resolved.

Accessible, performant checkout UI

Checkout is the highest-stakes screen in the app — a confusing or slow checkout directly costs conversions. Multi-step checkout (cart review → shipping → payment → confirm) should expose its current step to assistive technology, move focus to the new step's heading when the step changes, and never trap keyboard focus. Form validation errors should be associated with their inputs via aria-describedby and announced, not just shown as red text nobody using a screen reader will hear.

Performance in a catalog-and-checkout app is mostly about images: lazy-load offscreen product images with loading="lazy", reserve their layout space with explicit width/height or aspect-ratio to prevent layout shift, and avoid re-rendering the entire product grid when only the cart badge count changes — which is exactly what the split-context cart architecture above buys you for free.

Catalog types and the cart/wishlist reducer's action union

Prices are stored as integer cents to avoid floating-point rounding errors in totals. The action union is exhaustive — the reducer's switch can be checked for completeness by the compiler.

// catalog-types.ts
export interface Product {
  id: string;
  name: string;
  description: string;
  price: number; // in cents
  category: string;
  rating: number; // 0–5
  inStock: boolean;
  imageUrl: string;
  createdAt: string; // ISO date
}

export interface CartLine {
  productId: string;
  quantity: number;
}

export interface CartState {
  lines: CartLine[];
  wishlist: string[]; // product ids
}

export type CartAction =
  | { type: "cart/add"; productId: string; quantity?: number }
  | { type: "cart/remove"; productId: string }
  | { type: "cart/setQuantity"; productId: string; quantity: number }
  | { type: "cart/clear" }
  | { type: "wishlist/toggle"; productId: string }
  | { type: "wishlist/moveToCart"; productId: string };

Code Examples

Each concern is a small pure function you can unit test with a plain array of fixtures — no rendering required. buildVisibleCatalog composes them in a fixed, predictable order (search narrows first since it's usually the most selective, then filter, then sort last since sorting is cheapest to do on an already-narrowed list).

return sortProducts(filtered, sortKey);

useMemo recomputes the visible list only when products, query, filters, or sortKey actually change — typing in an unrelated field or a cart update elsewhere in the tree doesn't re-run search/filter/sort. The SearchBox keeps its own fast-updating draft state locally and only calls the (expensive) onChange after a 250ms pause in typing, which is the standard debounce pattern for search-as-you-type.

const handle = setTimeout(() => onChange(draft), 250);

Splitting cart state and dispatch into two separate contexts is the key performance move: a component that only dispatches actions (like AddToCartButton on every product card in a grid of 60 items) subscribes to CartDispatchContext, whose value never changes identity — so none of those 60 buttons re-render when the cart's contents change. Only components that actually read state (the cart drawer, the header badge) re-render on updates.

const CartDispatchContext = createContext<React.Dispatch<CartAction> | null>(null);

Common mistake: Putting { state, dispatch } in one context value object — every dispatch-only consumer re-renders on every cart state change, because the object identity changes even though dispatch itself didn't.

The three-state status — checking / authenticated / anonymous — is what prevents the classic refresh bug: on app boot the auth provider verifies a stored token asynchronously, and until that resolves the route guard shows a loading state instead of prematurely redirecting an actually-logged-in user to /login. Capturing location in the redirect's state lets the login page send the user back to the page they originally wanted after a successful login.

if (status === "anonymous" || !user) { return <Navigate to="/login" state={{ from: location }} replace />; }

loading="lazy" defers offscreen product images until they're near the viewport, which matters a lot on a catalog page with dozens of cards. Explicit width/height plus aspect-ratio reserve the image's box before it loads, preventing the layout-shift jank that tanks Core Web Vitals. aria-labelledby ties the whole card to its product name for screen readers, and the out-of-stock note uses role="status" so it's announced without stealing focus.

// catalogQuery.ts
import type { Product } from "./catalog-types";

export interface CatalogFilters {
  category: string | null;
  maxPriceCents: number | null;
  inStockOnly: boolean;
}

export type SortKey = "price-asc" | "price-desc" | "rating" | "newest";

function searchProducts(products: Product[], query: string): Product[] {
  if (!query.trim()) return products;
  const q = query.trim().toLowerCase();
  return products.filter(
    (p) => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q),
  );
}

function filterProducts(products: Product[], filters: CatalogFilters): Product[] {
  return products.filter((p) => {
    if (filters.category && p.category !== filters.category) return false;
    if (filters.maxPriceCents !== null && p.price > filters.maxPriceCents) return false;
    if (filters.inStockOnly && !p.inStock) return false;
    return true;
  });
}

function sortProducts(products: Product[], sortKey: SortKey): Product[] {
  const copy = [...products];
  switch (sortKey) {
    case "price-asc":
      return copy.sort((a, b) => a.price - b.price);
    case "price-desc":
      return copy.sort((a, b) => b.price - a.price);
    case "rating":
      r
…
// useCatalog.ts
import { useMemo, useState } from "react";
import { buildVisibleCatalog, type CatalogFilters, type SortKey } from "./catalogQuery";
import type { Product } from "./catalog-types";

export function useCatalog(products: Product[]) {
  const [query, setQuery] = useState("");
  const [filters, setFilters] = useState<CatalogFilters>({
    category: null,
    maxPriceCents: null,
    inStockOnly: false,
  });
  const [sortKey, setSortKey] = useState<SortKey>("newest");

  const visibleProducts = useMemo(
    () => buildVisibleCatalog(products, query, filters, sortKey),
    [products, query, filters, sortKey],
  );

  return { query, setQuery, filters, setFilters, sortKey, setSortKey, visibleProducts };
}

// SearchBox.tsx — debounces keystrokes so the pipeline doesn't rerun on every char
import { useEffect, useState } from "react";

interface SearchBoxProps {
  value: string;
  onChange: (value: string) => void;
}

export function SearchBox({ value, onChange }: SearchBoxProps) {
  const [draft, setDraft] = useState(value);

  useEffect(() => {
    const handle = setTimeout(() => onChange(draft), 250);
    return () => clearTimeout(handle);
  }, [draft, onChange]);

  ret
…
// CartContext.tsx
import { createContext, useContext, useMemo, useReducer, type ReactNode } from "react";
import type { CartAction, CartState } from "./catalog-types";

const initialState: CartState = { lines: [], wishlist: [] };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "cart/add": {
      const qty = action.quantity ?? 1;
      const existing = state.lines.find((l) => l.productId === action.productId);
      const lines = existing
        ? state.lines.map((l) =>
            l.productId === action.productId ? { ...l, quantity: l.quantity + qty } : l,
          )
        : [...state.lines, { productId: action.productId, quantity: qty }];
      return { ...state, lines };
    }
    case "cart/remove":
      return { ...state, lines: state.lines.filter((l) => l.productId !== action.productId) };
    case "cart/setQuantity": {
      if (action.quantity <= 0) {
        return { ...state, lines: state.lines.filter((l) => l.productId !== action.productId) };
      }
      return {
        ...state,
        lines: state.lines.map((l) =>
          l.productId === action.productId ? { ...l, quantity: action.quantity } : l,
 
…
// ProtectedRoute.tsx
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "./AuthContext";
import type { ReactNode } from "react";

export function ProtectedRoute({ children }: { children: ReactNode }) {
  const { status, user } = useAuth(); // status: "checking" | "authenticated" | "anonymous"
  const location = useLocation();

  if (status === "checking") {
    return <div role="status" aria-live="polite">Checking your session…</div>;
  }

  if (status === "anonymous" || !user) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return <>{children}</>;
}

// Usage:
// <Route path="/checkout" element={<ProtectedRoute><CheckoutPage /></ProtectedRoute>} />

// LoginPage.tsx redirects back to where the user was headed after success:
import { useNavigate, useLocation } from "react-router-dom";

export function LoginPage() {
  const navigate = useNavigate();
  const location = useLocation();
  const from = (location.state as { from?: Location } | null)?.from?.pathname ?? "/";

  async function handleLogin(email: string, password: string) {
    // await auth.login(email, password) ...
    navigate(from, { replace: true });
  }


…
// ProductCard.tsx
import type { Product } from "./catalog-types";
import { AddToCartButton } from "./CartContext";

function formatPrice(cents: number): string {
  return (cents / 100).toLocaleString(undefined, { style: "currency", currency: "USD" });
}

export function ProductCard({ product }: { product: Product }) {
  return (
    <article aria-labelledby={`product-${product.id}-name`} style={{ border: "1px solid #e2e8f0" }}>
      <img
        src={product.imageUrl}
        alt={product.name}
        loading="lazy"
        width={320}
        height={320}
        style={{ aspectRatio: "1 / 1", width: "100%", objectFit: "cover" }}
      />
      <h3 id={`product-${product.id}-name`}>{product.name}</h3>
      <p>{formatPrice(product.price)}</p>
      {!product.inStock && <p role="status">Out of stock</p>}
      <AddToCartButton productId={product.id} />
    </article>
  );
}

Cart context split for render isolation

Splitting state and dispatch into two contexts keeps dispatch-only consumers from re-rendering when unrelated state changes.

ProductGrid (60 cards)
  └── AddToCartButton × 60  ──subscribes to──▶ CartDispatchContext (stable identity)
                                                     never re-renders on cart change

CartDrawer, HeaderBadge     ──subscribes to──▶ CartStateContext (changes on dispatch)
                                                     re-renders only these

Important Rules

Keep search, filter, and sort as separate pure functions composed in a pipeline, not one tangled function.

Debounce search input so the (potentially expensive) filter pipeline doesn't run on every keystroke.

Use a reducer once cart/wishlist state is mutated from more than a couple of independent components.

Split state and dispatch contexts so dispatch-only consumers don't re-render on state changes.

Model auth status as three states — checking / authenticated / anonymous — never just a boolean, to avoid a false redirect on refresh.

Store money as integer cents (or another exact type), never as floating-point dollars.

Lazy-load product images and reserve their layout box to avoid cumulative layout shift.

Where this shows up in production

This is essentially the architecture behind Shopify storefronts, Amazon's product grid, and any React-based storefront built on Next.js commerce templates: a derived, memoized catalog view; cart state in a reducer exposed through context (often backed by localStorage or a server cart for persistence); auth-gated checkout with a three-state auth status; and image lazy-loading as a baseline performance requirement, not an afterthought.

if (!user) { return <Navigate to="/login" />; } // checked immediately on mount, before auth status resolves if (status === "checking") return <Loading />; if (status === "anonymous") return <Navigate to="/login" />;

Treating 'no user yet' the same as 'confirmed logged out' bounces an actually-authenticated user to the login page for a flash on every page refresh, while their session token is still being verified.

<CartContext.Provider value={{ state, dispatch }}> Separate <CartStateContext.Provider value={state}> and <CartDispatchContext.Provider value={dispatch}>

A single combined context value is a new object every render, so every consumer — including ones that only need dispatch and never read state — re-renders whenever cart state changes anywhere in the app.

Why should cart state and cart dispatch be exposed through two separate React contexts instead of one combined object? A. React does not allow more than one value in a single context B. It's required for TypeScript to infer the action union correctly C. A component that only dispatches actions won't re-render when state changes, since dispatch's identity stays stable while a combined { state, dispatch } object gets a new identity every render D. Separate contexts make the reducer function run faster

C. A component that only dispatches actions won't re-render when state changes, since dispatch's identity stays stable while a combined { state, dispatch } object gets a new identity every render — useReducer's dispatch function has a stable identity across renders. Wrapping it together with state in one object creates a new object reference every time state changes, forcing every consumer — even ones that never read state — to re-render.

What bug does modeling auth status as only isAuthenticated: boolean (defaulting to false) typically cause in a protected route? A. It makes login impossible B. On page refresh, before an async token check resolves, an actually-logged-in user is briefly treated as logged out and redirected to /login C. It causes a TypeScript compile error D. It prevents the wishlist from working

B. On page refresh, before an async token check resolves, an actually-logged-in user is briefly treated as logged out and redirected to /login — A boolean can't represent 'not yet known.' Without a third 'checking' state, the guard evaluates before the async auth check finishes and incorrectly redirects a genuinely authenticated user during the brief window before their session is confirmed.

In the catalog pipeline, why is useMemo used around buildVisibleCatalog(products, query, filters, sortKey)? A. To make the array sort in place for better performance B. It's required syntax for any function that takes more than one argument C. To avoid re-running the search/filter/sort pipeline on renders where none of those four inputs actually changed D. To automatically debounce the search query

C. To avoid re-running the search/filter/sort pipeline on renders where none of those four inputs actually changed — useMemo recomputes its value only when a dependency changes. Without it, the catalog pipeline (search + filter + sort) would rerun on every render, including ones triggered by unrelated state like an open/closed cart drawer.

Build out the remaining pieces: a product details page with an image gallery and 'related products' section; a wishlist page that reads useCartState().wishlist, resolves product ids to full Product objects, and offers 'Move to cart' (dispatch wishlist/moveToCart); an API layer (fetchProducts, login, placeOrder) with typed responses and centralized error handling (a try/catch that maps network/HTTP errors to a user-facing message); and a multi-step checkout UI (cart review → shipping → payment → confirm) that moves focus to each step's heading on transition and shows a loading skeleton while placeOrder is in flight. Add at least one test (e.g. with React Testing Library) that verifies AddToCartButton dispatches the right action and that ProtectedRoute redirects an anonymous user. Hint: For the API layer, define a Result<T> = { ok: true; data: T } | { ok: false; error: string } return type for every fetch wrapper instead of throwing, so call sites handle errors as ordinary data rather than needing try/catch everywhere.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.