skip to content

Implement a React `useLocalStorage(key, initialValue)` hook that returns a value and a setter which also writes to localStorage. What does a naive implementation get wrong about reading storage, parsing, and server rendering?

level: seniorimportance: should knowfreq 44%

answer

  1. do not read storage every render
  2. null is not a parse error
  3. the stored value is untrusted input
  4. the setter should behave like useState's
  5. the server has no window

basics

~20 s

Read storage in a lazy useState initializer, not on every render; wrap JSON.parse and every storage call in try/catch because parsing and quota both throw; support functional updates so the write matches the new state; and on the server, where localStorage does not exist, render the initial value and read storage after mount.

solid answer

~50 s

The shape is `useState` seeded by a lazy initializer that reads and parses the stored item, plus a setter that computes the next value, calls `setState`, and writes `JSON.stringify(next)`. Four things a naive version gets wrong. It reads `localStorage.getItem` in the component body, so every render hits synchronous storage. It calls `JSON.parse` unguarded, so one corrupt or non-JSON entry throws on mount and takes the whole tree down — and `setItem` throws too, on quota or in restrictive privacy modes. It accepts only a plain value, so callers cannot do `setValue(v => v + 1)` and the write goes out of sync with the state. And it assumes `window` exists: during server rendering there is no `localStorage`, so the initializer must fall back to `initialValue` and any stored value has to be applied in an effect after mount, or the server-rendered output and the first client render disagree.

code

javascript · 30 lines
javascript
import { useState, useCallback } from 'react';

function readStored(key, fallback) {
  if (typeof window === 'undefined') return fallback;
  try {
    const raw = window.localStorage.getItem(key);
    return raw === null ? fallback : JSON.parse(raw);
  } catch {
    return fallback;
  }
}

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => readStored(key, initialValue));
  const set = useCallback(
    next => {
      setValue(prev => {
        const resolved = typeof next === 'function' ? next(prev) : next;
        try {
          window.localStorage.setItem(key, JSON.stringify(resolved));
        } catch {
          // quota exceeded or storage unavailable
        }
        return resolved;
      });
    },
    [key],
  );
  return [value, set];
}

go deeper

for a junior

Know that the initial read belongs in a lazy useState initializer, that values are stored as strings so JSON.stringify and JSON.parse bracket every access, and that getItem returns null when the key is absent.

for a middle

Explain each failure mode concretely: parse errors, the null-versus-parse distinction, quota errors on write, and why the setter must accept an updater so the persisted value matches the state that actually commits.

for a senior

Treat stored data as untrusted, versioned input and say what you would do when its shape changes between releases. Cover the server-rendering guard, the one-frame default, and why same-tab sync across two consumers does not come for free.

for a principal

Own the policy: what may be persisted client-side at all given privacy and eviction, how schema migration and a storage-version key are handled fleet-wide, and when persistence should move behind a single owned store instead of being sprinkled through components.

## A defensible implementation ```js import { useState, useCallback } from 'react'; function readStored(key, fallback) { if (typeof window === 'undefined') return fallback; try { const raw = window.localStorage.getItem(key); return raw === null ? fallback : JSON.parse(raw); } catch { return fallback; } } function useLocalStorage(key, initialValue) { const [value, setValue] = useState(() => readStored(key, initialValue)); const set = useCallback( next => { setValue(prev => { const resolved = typeof next === 'function' ? next(prev) : next; try { window.localStorage.setItem(key, JSON.stringify(resolved)); } catch { /* quota exceeded or storage unavailable */ } return resolved; }); }, [key], ); return [value, set]; } ``` ## Reading: lazy initializer, not the render body `useState(readStored(key, initialValue))` evaluates the read on every single render and discards the result on all but the first — `localStorage` is a synchronous, main-thread API, so that is real jank on a component that renders often. Passing the *function* instead makes React call it only when the state is first created. This is the standard lazy-initial-state form and it is the single most common miss in this exercise. ## Parsing: everything about storage can throw Three distinct failure modes, all of which have taken down real apps: - `JSON.parse` throws a `SyntaxError` on anything that is not valid JSON. A key that was once written as a raw string, a half-written entry, a value written by an older version of the app — any of these turn into a crash during render, which no error boundary should have to catch. - `getItem` returns `null` for a missing key, and `JSON.parse(null)` returns `null` rather than throwing — so a `null` check must come *before* parsing, or `initialValue` is silently replaced by `null`. - `setItem` throws `QuotaExceededError` when storage is full, and touching `localStorage` at all can throw in some privacy configurations. Wrapping the write is not paranoia. A production version usually goes further and validates the parsed shape (with a schema check, or at minimum a type guard) before trusting it, because the stored value is effectively untrusted input written by an older build of your own app. ## Writing: accept an updater Callers expect the same ergonomics as `useState`, and more importantly the updater form is what keeps the write correct when several updates land in the same batch. Resolving the next value *inside* `setValue(prev => …)` gives you the pending state to compute from, and lets the storage write happen with the same value that is about to be committed. A version that reads the current `value` from the closure and writes that instead will drift the moment two updates batch — state and storage then disagree, and the disagreement survives a reload. (Performing the `setItem` inside the state updater is a side effect in a function React may call more than once. It is idempotent here — writing the same key twice is harmless — which is what makes it acceptable; the alternative is to compute the resolved value first and write after, at the cost of duplicating the updater logic.) ## Server rendering: there is no localStorage On the server `window` is undefined, so the guard is mandatory just to avoid a `ReferenceError`. The subtler issue is that the value the server rendered came from `initialValue`, while the browser could produce a different stored value on its very first render — the two must agree for the initial client render, so the stored value has to be applied *after* mount, in an effect, accepting one frame of the default. That is why a theme toggle built this way flashes unless a small inline script sets the attribute before React runs. Deciding which of those two strategies applies is a rendering-architecture question; the hook's own responsibility is simply not to read `window` where it does not exist. ## Two components, one key The hook is per component instance: two components using the same key each hold their own `useState`, so a write from one does not update the other until something re-renders it from storage. The `storage` event fires only in *other* tabs, never in the tab that made the change, so subscribing to it fixes cross-tab sync but not same-tab sync. If several components genuinely need one live value, the value belongs in shared state that happens to persist, with the hook used once at the top — the local-storage hook is a persistence adapter, not a state-sharing mechanism. ## Also: the key can change If `key` is a prop, the state does not follow it — the value stays on the old key's data until something re-reads. Making the key part of the component's `key` prop, or re-reading in an effect keyed on `key`, is the honest fix; silently ignoring it is the bug people ship.

  • How would you keep two browser tabs in sync with this hook?
    Subscribe to the window `storage` event, which fires in every *other* tab when a key changes, and update state when the event's key matches. Note it never fires in the tab that wrote the value, so it complements the local write rather than replacing it. For an external mutable source that must be read safely during render, `useSyncExternalStore` is the API designed for the subscription.
  • What is the flash-of-default problem with a persisted theme, and whose job is it to fix?
    The server-rendered output necessarily carries the default theme because storage is a client-only source, so the user sees the default until the client reads storage and re-renders. The hook cannot fix it; the usual fix is a tiny blocking inline script that sets a class or data attribute on the document element before the app renders, with React reading from there.
  • Why validate the parsed value rather than trusting it?
    Because storage is a durable side channel written by every version of your app that ever ran on that device, and by anything else on the origin. A value that was an array last release may be an object now, and code that assumes the new shape crashes on the old entry. Parsing into a schema check and falling back to the initial value turns a crash into a reset.

saying these in an interview costs you the question

  • Calls localStorage.getItem directly in the component body
  • Leaves JSON.parse unguarded because 'we only ever write JSON'
  • Assumes setItem cannot fail
  • Expects two components on the same key to stay in sync automatically
  • Thinks the storage event fires in the tab that wrote the value

context