Skip to main content

Command Palette

Search for a command to run...

System Design: Design a Shopping Cart and Checkout Flow

Day 30 of 40 — React System Design Series

Updated
9 min read
System Design: Design a Shopping Cart and Checkout Flow
R

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.

System Design: Design a Shopping Cart and Checkout Flow

Day 30 of 40 — React System Design Series


Hey everyone, Richa here! 👋

cart gif

Day 30. End of Week 6.

The shopping cart. Every frontend engineer has built one at some point. And every frontend engineer has also discovered that what looks like "just a list of items with a total" is actually one of the richest system design problems in frontend.

State that needs to survive page refresh. Optimistic updates that need to handle failure. Stock limits. Quantity changes. Checkout flows that span multiple steps. Payment integration. Error recovery.

Let's design the whole thing.


Clarifying questions

  • Does the cart need to persist across sessions? (Close the tab, come back tomorrow — cart still there)

  • Is the cart server-side or client-side? (Guest carts? Or logged-in only?)

  • Multi-currency or single currency?

  • Stock limits — can you add more than available stock?

  • Multi-step checkout or single page?

  • Payment provider? (Stripe is the standard choice)

Assumed answers:

  • Persistent cart (survives page refresh and browser close)

  • Server-side cart for logged-in users (so it persists across devices)

  • Single currency

  • Stock limits enforced (can't add more than available)

  • Multi-step checkout (cart → shipping → payment → confirmation)

  • Stripe for payment


Architecture overview

Cart State
  ├── Server (logged-in users)    ← source of truth
  └── localStorage (guests)       ← synced on login

Cart Store (Zustand)
  ├── items: CartItem[]
  ├── addItem()    → optimistic update → POST /cart/items
  ├── removeItem() → optimistic update → DELETE /cart/items/:id
  └── updateQty()  → optimistic update → PATCH /cart/items/:id

Checkout Flow (state machine)
  ├── CART       (view/edit items)
  ├── SHIPPING   (address form)
  ├── PAYMENT    (Stripe Elements)
  └── CONFIRMED  (order summary)

The cart store — Zustand with server sync

The cart needs to be immediately responsive (add item = instant visual feedback) but also server-backed (survive refresh, sync across devices).

// stores/cartStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { cartService } from '@/services/cartService';

export type CartItem = {
  id: string;         // cart item ID (server-generated)
  productId: string;
  name: string;
  price: number;      // price at time of add (snapshot — prices may change)
  quantity: number;
  imageUrl: string;
  maxQuantity: number; // available stock
};

type CartStore = {
  items: CartItem[];
  isLoading: boolean;
  error: string | null;

  // Actions
  initCart: () => Promise<void>;                              // load from server on mount
  addItem: (product: Omit<CartItem, 'id' | 'quantity'>) => Promise<void>;
  removeItem: (itemId: string) => Promise<void>;
  updateQuantity: (itemId: string, quantity: number) => Promise<void>;
  clearCart: () => void;

  // Derived
  totalItems: () => number;
  totalPrice: () => number;
};

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      isLoading: false,
      error: null,

      initCart: async () => {
        set({ isLoading: true });
        try {
          const serverCart = await cartService.getCart();
          set({ items: serverCart.items, isLoading: false });
        } catch {
          set({ isLoading: false }); // use cached items from localStorage if server fails
        }
      },

      addItem: async (product) => {
        const optimisticItem: CartItem = {
          ...product,
          id: `temp-${Date.now()}`,  // temp ID until server responds
          quantity: 1,
        };

        // Optimistically add to UI
        set(state => ({ items: [...state.items, optimisticItem] }));

        try {
          const savedItem = await cartService.addItem(product.productId);
          // Replace temp item with real server item (real ID)
          set(state => ({
            items: state.items.map(item =>
              item.id === optimisticItem.id ? savedItem : item
            ),
          }));
        } catch (err) {
          // Roll back on failure
          set(state => ({
            items: state.items.filter(item => item.id !== optimisticItem.id),
            error: 'Failed to add item. Please try again.',
          }));
        }
      },

      removeItem: async (itemId) => {
        const previousItems = get().items;

        // Optimistically remove
        set(state => ({ items: state.items.filter(i => i.id !== itemId) }));

        try {
          await cartService.removeItem(itemId);
        } catch {
          // Roll back
          set({ items: previousItems, error: 'Failed to remove item.' });
        }
      },

      updateQuantity: async (itemId, quantity) => {
        if (quantity <= 0) {
          return get().removeItem(itemId);
        }

        const previousItems = get().items;

        // Optimistically update
        set(state => ({
          items: state.items.map(item =>
            item.id === itemId ? { ...item, quantity } : item
          ),
        }));

        try {
          await cartService.updateQuantity(itemId, quantity);
        } catch {
          set({ items: previousItems, error: 'Failed to update quantity.' });
        }
      },

      clearCart: () => set({ items: [] }),

      totalItems: () => get().items.reduce((sum, item) => sum + item.quantity, 0),

      totalPrice: () =>
        get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    }),
    {
      name: 'cart',    // localStorage key
      partialize: (state) => ({ items: state.items }),  // only persist items
    }
  )
);

💡 Price snapshot — store the price at the time of adding to cart, not a live reference to the product price. If a price changes between "add to cart" and checkout, the user should be notified — not silently charged a different amount.


The cart UI — quantity controls and stock limits

// components/CartItem.tsx
function CartItemRow({ item }: { item: CartItem }) {
  const { updateQuantity, removeItem } = useCartStore();

  return (
    <div className="cart-item">
      <img src={item.imageUrl} alt={item.name} />

      <div className="cart-item-details">
        <p className="item-name">{item.name}</p>
        <p className="item-price">£{item.price.toFixed(2)}</p>
      </div>

      <div className="quantity-controls">
        <button
          onClick={() => updateQuantity(item.id, item.quantity - 1)}
          disabled={item.quantity <= 1}
          aria-label="Decrease quantity"
        >
          −
        </button>

        <span aria-label={`Quantity: ${item.quantity}`}>{item.quantity}</span>

        <button
          onClick={() => updateQuantity(item.id, item.quantity + 1)}
          disabled={item.quantity >= item.maxQuantity}  // enforce stock limit
          aria-label="Increase quantity"
          title={item.quantity >= item.maxQuantity ? `Only ${item.maxQuantity} in stock` : undefined}
        >
          +
        </button>
      </div>

      <p className="item-subtotal">£{(item.price * item.quantity).toFixed(2)}</p>

      <button onClick={() => removeItem(item.id)} aria-label="Remove item">
        ✕
      </button>
    </div>
  );
}

Multi-step checkout — a simple state machine

Checkout has a defined sequence of steps. Model it as a state machine — not just a step number.

// hooks/useCheckout.ts
type CheckoutStep = 'cart' | 'shipping' | 'payment' | 'confirmed';

type ShippingDetails = {
  name: string;
  address: string;
  city: string;
  postcode: string;
  country: string;
};

type CheckoutState = {
  step: CheckoutStep;
  shippingDetails: ShippingDetails | null;
  orderId: string | null;
};

export function useCheckout() {
  const [state, setState] = useState<CheckoutState>({
    step: 'cart',
    shippingDetails: null,
    orderId: null,
  });

  const { items, clearCart } = useCartStore();

  const goToShipping = useCallback(() => {
    if (items.length === 0) return; // can't proceed with empty cart
    setState(s => ({ ...s, step: 'shipping' }));
  }, [items]);

  const submitShipping = useCallback((details: ShippingDetails) => {
    setState(s => ({ ...s, shippingDetails: details, step: 'payment' }));
  }, []);

  const submitPayment = useCallback(async (paymentMethodId: string) => {
    if (!state.shippingDetails) return;

    const order = await orderService.createOrder({
      items,
      shipping: state.shippingDetails,
      paymentMethodId,
    });

    clearCart();
    setState(s => ({ ...s, orderId: order.id, step: 'confirmed' }));
  }, [state.shippingDetails, items, clearCart]);

  const goBack = useCallback(() => {
    setState(s => ({
      ...s,
      step: s.step === 'payment' ? 'shipping' :
            s.step === 'shipping' ? 'cart' : s.step,
    }));
  }, []);

  return { ...state, goToShipping, submitShipping, submitPayment, goBack };
}

Stripe integration — the payment step

// components/checkout/PaymentStep.tsx
import { useState } from 'react';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';

type PaymentStepProps = {
  onSuccess: (paymentMethodId: string) => Promise<void>;
  onBack: () => void;
  amount: number;
};

export function PaymentStep({ onSuccess, onBack, amount }: PaymentStepProps) {
  const stripe = useStripe();
  const elements = useElements();
  const [isProcessing, setIsProcessing] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    setIsProcessing(true);
    setError(null);

    const cardElement = elements.getElement(CardElement);
    if (!cardElement) return;

    const { error: stripeError, paymentMethod } = await stripe.createPaymentMethod({
      type: 'card',
      card: cardElement,
    });

    if (stripeError) {
      setError(stripeError.message ?? 'Payment failed');
      setIsProcessing(false);
      return;
    }

    try {
      await onSuccess(paymentMethod.id);
    } catch {
      setError('Order failed. Your card was not charged.');
      setIsProcessing(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="payment-form">
      <h2>Payment</h2>
      <p className="amount-due">Total: £{(amount / 100).toFixed(2)}</p>

      <CardElement
        options={{ style: { base: { fontSize: '16px' } } }}
      />

      {error && <p className="payment-error" role="alert">{error}</p>}

      <div className="checkout-actions">
        <button type="button" onClick={onBack} disabled={isProcessing}>
          ← Back
        </button>
        <button type="submit" disabled={isProcessing || !stripe}>
          {isProcessing ? 'Processing...' : 'Pay Now'}
        </button>
      </div>
    </form>
  );
}

Interview talking points

Decision What to say
Client vs server cart "Server for logged-in users (persists across devices). localStorage as fallback for guests — sync to server on login"
Optimistic updates "Add/remove should feel instant. Rollback on failure with a visible error — don't silently lose the item"
Price snapshot "Snapshot the price at add time. If price changes before checkout, notify the user — don't silently charge more"
Multi-step as state machine "Model steps as named states — not step numbers. Makes transitions explicit and the logic easier to reason about"
Payment "Never handle card data directly. Stripe Elements keeps card data in an iframe — your server never sees raw card numbers"

Week 6 wrap-up

This week we designed five complete systems:

  • Day 26 — Social media feed (infinite scroll, cursor pagination, new-posts banner, optimistic likes)

  • Day 27 — Autocomplete (debounce, caching, cancellation, keyboard navigation)

  • Day 28 — Real-time dashboard (polling vs SSE vs WebSockets, per-widget intervals)

  • Day 29 — Full auth system (httpOnly cookies, three guard types, refresh queue)

  • Day 30 — Shopping cart + checkout (Zustand with optimistic updates, Stripe, state machine)

Next week: Testing — unit tests, integration tests, and E2E. The unglamorous topic that separates professional engineers from everyone else.

week done gif

See you Monday for Day 31 — Unit testing React components with Vitest and Testing Library.


Day 29 — Design a Full Auth System Day 31 — Unit Testing React Components


Part of the React System Design Series — 40 days, one topic per day.

React System Design — Learning in Public

Part 30 of 34

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js — and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing — the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Up next

Testing Philosophy — What to Test, What Not to Test, and Why Most Tests Are Wrong

Day 31 of 40 — React System Design Series