Back to Machine Coding
Machine Coding
easy
mid

How would you persist cart state across page refreshes using localStorage?

Lazy-init state from localStorage, sync to it with useEffect on change. Wrap reads/writes in try/catch (JSON parse errors, quota, private mode), debounce writes if frequent, version the schema, and sync across tabs with the storage event. Best packaged as a useLocalStorage hook.

5 min read·~20 min to think through

Persisting cart state is a useState + localStorage bridge. The implementation is short; the robustness details are the interview.

The core pattern — a reusable hook

jsx
function useLocalStorage(key, initialValue) {
  // lazy initializer — read localStorage once, on mount
  const [value, setValue] = useState(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw ? JSON.parse(raw) : initialValue;
    } catch {
      return initialValue; // corrupt JSON, private mode, etc.
    }
  });

  // write back whenever value changes
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // quota exceeded / private mode — fail silently or notify
    }
  }, [key, value]);

  return [value, setValue];
}

// usage
const [cart, setCart] = useLocalStorage("cart:v1", []);

What interviewers are grading

1. Lazy initialization — pass a function to useState so localStorage is read once on mount, not on every render.

2. try/catch everywhereJSON.parse throws on corrupt data; setItem throws on quota-exceeded or in Safari private mode; localStorage may be unavailable (SSR, disabled). Never let storage crash the app.

3. SSR safetylocalStorage doesn't exist on the server. Guard with typeof window !== "undefined", or only read inside useEffect, to avoid hydration mismatches.

4. Cross-tab sync — listen for the storage event so a cart change in one tab updates the others:

js
useEffect(() => {
  const onStorage = (e) => {
    if (e.key === key && e.newValue) setValue(JSON.parse(e.newValue));
  };
  window.addEventListener("storage", onStorage);
  return () => window.removeEventListener("storage", onStorage);
}, [key]);

5. Schema versioning — key it cart:v1. When the cart shape changes, bump the version (or migrate) so stale data doesn't crash the new code.

6. Debounce writes — if the cart updates rapidly, debounce setItem (it's synchronous and can jank).

Senior considerations

  • localStorage is not source of truth — it's a cache for guests. For logged-in users, the server owns the cart; reconcile/merge on login.
  • Sensitive data — never persist tokens or PII in localStorage (XSS-readable).
  • Stale prices/availability — re-validate cart contents against the server on load; a price from last week may be wrong.

The framing

"I'd build a useLocalStorage hook: lazy-init from storage on mount, write back in a useEffect on change. The robustness is the real answer — try/catch around parse and setItem for corrupt data, quota, and private mode; SSR guards to avoid hydration mismatch; a storage event listener for cross-tab sync; and a versioned key so schema changes don't break old data. And conceptually: localStorage is a guest cache, not source of truth — re-validate against the server and merge on login."

Follow-up questions

  • Why pass a function to useState instead of reading localStorage directly?
  • How do you sync the cart across multiple browser tabs?
  • What happens if localStorage is full or disabled?
  • How do you handle a localStorage schema change between app versions?

Common mistakes

  • Reading localStorage on every render instead of a lazy initializer.
  • No try/catch — corrupt JSON or quota errors crash the app.
  • Accessing localStorage during SSR, causing hydration mismatches.
  • No cross-tab sync, so tabs show different carts.
  • Persisting tokens or sensitive data in localStorage.

Performance considerations

  • localStorage access is synchronous and blocks the main thread — debounce frequent writes and keep stored payloads small. JSON.stringify/parse of a large cart on every change can jank; batch updates.

Edge cases

  • Corrupt or non-JSON value already in storage.
  • Quota exceeded (~5MB) or Safari private mode throwing on setItem.
  • Two tabs editing the cart simultaneously.
  • Old schema version persisted before an app update.

Real-world examples

  • E-commerce guest carts surviving refreshes before the user logs in.
  • Persisting UI preferences (filters, theme) via a useLocalStorage hook.

Senior engineer discussion

Seniors package it as a reusable hook, handle every failure mode (parse errors, quota, private mode, SSR), add cross-tab sync and schema versioning, and reframe localStorage as a guest-only cache that must be reconciled with the server-owned cart on login.

Related questions