Build a Shopping Cart UI
Cart state: array of line items {id, name, price, qty}. Operations: add (merge if exists), update qty, remove, clear. Derive totals (subtotal/tax/total) — don't store them. Watch: qty bounds, empty cart state, persistence (localStorage), and that server validates prices.
A shopping cart is a CRUD list with derived totals — the interview is in the derivations and edge cases.
State
const [items, setItems] = useState([]); // [{ id, name, price, qty }]Operations
const addItem = (product) =>
setItems((items) => {
const existing = items.find((i) => i.id === product.id);
if (existing) { // MERGE, don't duplicate
return items.map((i) =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i);
}
return [...items, { ...product, qty: 1 }];
});
const updateQty = (id, qty) =>
setItems((items) =>
qty <= 0
? items.filter((i) => i.id !== id) // qty 0 → remove
: items.map((i) => (i.id === id ? { ...i, qty } : i)));
const removeItem = (id) => setItems((items) => items.filter((i) => i.id !== id));
const clearCart = () => setItems([]);Totals — DERIVED, never stored
const subtotal = useMemo(
() => items.reduce((sum, i) => sum + i.price * i.qty, 0),
[items]
);
const tax = subtotal * TAX_RATE;
const total = subtotal + tax + shipping;Storing total in state means it can desync from items — compute it. This is the single most important point.
The details being graded
- Add merges by product id (
qty + 1), doesn't add a duplicate line. - Quantity bounds — can't go below 1 (or 0 → remove); cap at stock; reject non-integers.
- Derived totals, memoized — not stored state.
- Stable
idkeys for line items. - Immutable updates throughout.
- Empty cart state — a real "your cart is empty" UI, not a blank box.
- Persistence —
localStorageso the cart survives refresh (it's a guest cache; reconcile with the server on login). - Money formatting —
Intl.NumberFormat, and watch floating-point (work in cents, or round consistently).
The senior point: trust
The cart UI is convenience, not source of truth. Prices, availability, and the final total must be validated/recomputed on the server at checkout — a client-side price is editable. Re-validate cart contents (price changes, out-of-stock) on load and at checkout.
Scaling
useReducer for the operations; Context or a store if the cart is needed app-wide (header badge, etc.); optimistic updates if server-backed.
The framing
"Cart state is an array of line items {id, name, price, qty}. Add merges by product id rather than duplicating; updateQty clamps at 1 (or removes at 0). The key principle: totals — subtotal, tax, total — are derived with useMemo, never stored, or they desync from the items. Then the details: stable id keys, immutable updates, a real empty-cart state, localStorage persistence, and careful money formatting in cents to avoid float errors. And the senior caveat — the cart is a convenience layer; the server must re-validate prices and totals at checkout."
Follow-up questions
- •Why should totals be derived rather than stored?
- •Why must add() merge instead of pushing a duplicate?
- •Why can't the client-computed total be trusted?
- •How do you avoid floating-point errors with money?
Common mistakes
- •Storing total/subtotal in state — desyncs from items.
- •add() pushing a duplicate line instead of incrementing qty.
- •No quantity bounds — negative or zero quantities.
- •Trusting the client-side price/total at checkout.
- •Float math on money instead of cents; no empty-cart state.
Performance considerations
- •Small scale typically. Memoize derived totals so they don't recompute on unrelated renders; memoize line-item rows. The real concern is correctness (derivation, money math), not speed.
Edge cases
- •Adding an item already in the cart.
- •Quantity set to 0 or negative.
- •Item goes out of stock or changes price while in the cart.
- •Empty cart.
- •Very large quantities or many distinct items.
Real-world examples
- •Any e-commerce cart; cart state often in Context/Zustand for the header badge.
- •Guest carts persisted to localStorage, merged with the server cart on login.