How would you implement a shopping cart with optimistic updates in React?

Advanced20 min interview
Skills tested:
Optimistic UI PatternsState Rollback StrategiesConcurrent Mutation HandlingIdempotent API DesignLocal PersistenceReact State Management

Advertisement

🧩 Scenario

You're building an ecommerce shopping cart where user actions should feel instantaneous. - Add, remove, and update item quantities optimistically - Persist changes to a backend service - Roll back failed mutations safely - Show per-item sync status and global synchronization state - Recover cart data after refresh using local persistence The cart must remain consistent even when multiple updates occur concurrently or network requests fail.

🧠 Architecture Walkthrough

The Optimistic Update Contract: Snapshot, Apply, Sync, Rollback

Every optimistic mutation in this cart follows the same four-step contract, implemented inside the optimisticUpdate function. First, take a deep snapshot of the current state before making any changes this is the recovery point.

Second, apply the intended change to local state immediately so the UI reflects the user's action without waiting for the network.

Third, fire the async API call. Fourth, on success, clear the pending flags and commit; on failure, restore the snapshot and surface an error.

The critical insight is that the snapshot must be taken before setCart is called if it is taken after, you are snapshotting the already-mutated state and lose the ability to roll back.

The demo stores the snapshot in snapshotRef.current rather than component state, because a ref persists across renders without triggering them. Using state for the snapshot would cause an extra render on every mutation before the API call even starts.

Deep Clone vs. Shallow Copy in Snapshot Logic

The demo uses JSON.parse(JSON.stringify(cart)) in two places: when taking the snapshot (const prev = JSON.parse(JSON.stringify(cart))), and when applying the optimistic function inside setCart. This is intentional.

The cart object contains nested structures each item is { quantity: number, pending: boolean } and the applyFn mutates the object it receives directly (e.g., c[productId] = { ...existing, quantity: existing.quantity + 1, pending: true }).

A shallow copy would mean applyFn receives references to the same nested item objects as cart, so mutations would corrupt the original state even before setCart is called. The deep clone ensures the function operates on a fully independent copy.

In a production system with more complex cart item shapes, you would replace JSON.parse(JSON.stringify(...)) with a structured clone utility or a library like structuredClone (native in modern browsers) to handle types that do not survive JSON round-tripping such as Date and undefined.

Per-Item Pending Flags as Optimistic UI Signals

Each cart item carries a pending: boolean field that is set to true when an optimistic update is applied and cleared to false on API success. In the rendered cart list, item.pending controls whether a "SYNCING" badge appears next to the product name.

This per-item signal is more informative than a single global loading spinner it tells the user exactly which items are still in flight, so they can see that MacBook Pro is syncing while AirPods Pro is already confirmed. The risk of co-locating this UI flag with domain data is that it can leak into the server payload if you send the entire cart object without stripping it.

The demo's apiSyncCart receives a patch object that is constructed manually ({ action: 'add', productId, qty: 1 }) rather than the full cart item, which sidesteps this concern. In a typed TypeScript codebase, you would want a CartItem domain type and a separate CartItemUI type that extends it with pending, keeping the layers explicitly separated.

💡 Key Code Explained

const optimisticUpdate = async (applyFn, patchForServer, description) => {
  setError(null);

  const prev = JSON.parse(JSON.stringify(cart));
  snapshotRef.current = prev;

  setCart((currentCart) => {
    const next = applyFn(JSON.parse(JSON.stringify(currentCart)));
    return next;
  });

  setSyncing(true);

  try {
    await apiSyncCart(patchForServer);
    setSyncing(false);
    setCart((currentCart) => {
      const cleared = { ...currentCart };
      Object.keys(cleared).forEach((k) => {
        if (cleared[k].pending) cleared[k].pending = false;
      });
      return cleared;
    });
  } catch (err) {
    setCart(snapshotRef.current || {});
    setSyncing(false);
    setError(`Failed to ${description}: ${err.message}`);
    setTimeout(() => setError(null), 5000);
  }
};

This single function is the entire optimistic update engine. Notice that setCart is called with a functional updater (currentCart) => ... rather than the captured cart variable this is essential for correctness under concurrent updates. If two addToCart calls fire in quick succession, both may close over the same stale cart value.

The functional updater form ensures each call receives the latest state at the time React processes it. The applyFn receives its own deep clone so mutations inside it never corrupt the committed state.

The catch block restores the snapshot via setCart(snapshotRef.current || {}), which is a direct state replacement React will re-render with the rolled-back values, and the user sees their action undone along with the error message explaining why.

const addToCart = (productId) => {
  optimisticUpdate(
    (c) => {
      const existing = c[productId] || { quantity: 0, pending: false };
      c[productId] = {
        ...existing,
        quantity: existing.quantity + 1,
        pending: true,
      };
      return c;
    },
    { action: 'add', productId, qty: 1 },
    'add item to cart',
  );
};

The applyFn here handles both the "add new item" and "increment existing item" cases in one expression. c[productId] || { quantity: 0, pending: false } provides the default so that the first add creates the item with quantity: 1, and subsequent adds increment from whatever the current quantity is.

The pending: true flag is set here in the optimistic apply, and cleared later in the success path of optimisticUpdate. This pattern each action function is just a applyFn + a server patch description keeps the individual action functions minimal and makes optimisticUpdate the single place where the snapshot/sync/rollback lifecycle lives.

⚖️ Tradeoffs

ApproachProCon
Single snapshotRef with functional setCart updaterSimple to reason about for sequential operationsConcurrent rapid-fire actions can produce unexpected rollbacks since snapshot is overwritten on each call
Per-operation snapshot queue (array of snapshots)Correct rollback for every in-flight request independentlySignificantly more complex; requires matching responses to their specific snapshot
Server-authoritative reconciliation (no local snapshot)Always shows the truth; no stale rollback riskFeels slow; every action requires a round-trip before the UI updates
React Query / SWR mutate with rollbackOnErrorHandles optimistic updates, caching, and rollback with minimal codeAdds library dependency; the internal mechanics are abstracted away from the learning perspective

🎯 What Interviewers Actually Check

  • Whether you use a functional updater (setCart(prev => ...)) rather than a captured variable using a stale closure in concurrent mutations is the most common bug candidates write
  • Whether you know why JSON.parse(JSON.stringify(...)) is used instead of a spread and what types it fails to preserve
  • Whether you strip UI state (pending) from the server payload sending pending: true to the API is a data modeling error
  • Whether you explain the snapshotRef being a ref (not state) and why extra renders during the snapshot/apply sequence create subtle race conditions
  • Whether you can describe what "idempotency" means for the apiSyncCart call and why it matters if the request is retried after a network timeout

❓ Follow-Up Questions

  1. How would you refactor optimisticUpdate to maintain a queue of in-flight snapshots so that each concurrent operation can roll back independently without corrupting other pending changes?
  2. The demo's apiSyncCart has a 25% simulated failure rate. How would you add exponential backoff retry logic before giving up and rolling back?
  3. If two browser tabs are open and the user adds an item in tab A, how do you reconcile the cart state in tab B which still reflects the pre-add snapshot in localStorage?
  4. How would you write a test for the rollback path specifically verifying that after a simulated API failure, the cart returns to exactly the pre-mutation state?
  5. Your backend team says they cannot guarantee idempotency on the add-to-cart endpoint. What changes to the client architecture does that force, and how do you prevent duplicate items from appearing on retry?

🎮 Live Demo

📝 Summary

Optimistic updates require a disciplined four-step contract snapshot, apply locally, sync with server, then commit or rollback and the optimisticUpdate function in this demo implements all four in one place so that individual actions like addToCart and removeFromCart stay minimal.

The functional updater form of setCart is non-negotiable for concurrent correctness, and the deep clone of cart state before mutation prevents the applyFn from accidentally corrupting the committed state. Per-item pending flags communicate in-flight status at a granular level that a global spinner cannot match.

The key production concern this demo intentionally leaves as an exercise is the single snapshotRef weakness under rapid concurrent actions a real implementation would use a queue of in-flight operations with per-request snapshot tracking, or offload the complexity to a library like React Query that handles this correctly by default.

Frequently Asked Questions

Why use optimistic updates?

They make the UI feel snappy by showing the result immediately before the server confirms the change.

How to handle conflicts or failures?

Keep previous state snapshot, attempt retry, show error, and rollback if necessary. Use idempotent APIs and versioning.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement