System Design: Design a Shopping Cart and Checkout Flow
Day 30 of 40 — React System Design Series

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! 👋
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.
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.






