Back to React
React
easy
mid

How would you implement a polyfill for the useState hook?

`useState(initial)` returns `[value, setter]`. Polyfill via the component's hook slot array: on first render, store initial; on subsequent renders, return whatever's in the slot. The setter schedules a re-render (with batching) and writes the new value into the slot.

4 min read·~20 min to think through

useState is the simplest hook, but the implementation surfaces the slot model that all hooks share.

Behavior

js
const [count, setCount] = useState(0);
// or:
const [count, setCount] = useState(() => expensiveInitial());  // lazy
  • Returns [currentValue, setter].
  • setter(v) schedules a re-render with the new value.
  • setter(prev => next) uses the functional form — safe under batched updates.
  • Lazy initializer (useState(() => ...)) runs once on mount.
  • Bail-out: if the new value is Object.is-equal to the current, React skips the re-render.

The polyfill

Each component has an ordered list of hook slots and a render index that resets each render.

js
let currentComponent = null;

function renderComponent(component) {
  currentComponent = component;
  component.hookIndex = 0;
  component.scheduledRender = false;
  const ui = component.render();
  currentComponent = null;
  commit(ui, component.dom);
}

function useState(initial) {
  const c = currentComponent;
  const i = c.hookIndex++;

  if (c.hooks[i] === undefined) {
    c.hooks[i] = typeof initial === "function" ? initial() : initial;
  }

  const setState = (next) => {
    const newValue = typeof next === "function" ? next(c.hooks[i]) : next;
    if (Object.is(newValue, c.hooks[i])) return;        // bail out
    c.hooks[i] = newValue;
    scheduleRender(c);                                  // batched
  };

  return [c.hooks[i], setState];
}

scheduleRender — batching

In a naive version, scheduleRender calls renderComponent synchronously. In React, it batches updates within the same event loop tick:

js
const pending = new Set();
let scheduled = false;

function scheduleRender(c) {
  pending.add(c);
  if (!scheduled) {
    scheduled = true;
    queueMicrotask(() => {
      const toRender = [...pending];
      pending.clear();
      scheduled = false;
      for (const c of toRender) renderComponent(c);
    });
  }
}

React's batching is more sophisticated (priorities, lanes), but the model is the same — multiple setState calls in one handler produce one render.

The functional setter

setState(prev => next) matters when multiple updates queue:

js
setCount(c => c + 1);
setCount(c => c + 1);    // ends at +2

vs:

js
setCount(count + 1);
setCount(count + 1);     // ends at +1 — both saw stale count

Implement by storing the queued updates and applying them in order during the next render's slot read.

The rules-of-hooks invariant

The slot model depends on hooks being called in the same order each render — that's why conditional hooks are forbidden. The slot at index 3 must always be the same useState; if you skip it conditionally, every subsequent slot shifts.

Lazy initial state

If the initial value is expensive, pass a function:

js
const [data] = useState(() => parseLargeBlob());

The function runs only on mount, not every render — important for big initializers.

Interview framing

"Each component has an ordered slot list and a hookIndex that resets per render. useState reads slot i (initializing on the first render — supporting a lazy initializer function); returns [value, setter]. The setter resolves the next value (functional or direct), bails out if Object.is-equal, otherwise writes the new value and schedules a re-render. Updates within the same tick are batched — multiple setStates produce one render. The whole hook system depends on stable call order — that's why you can't call hooks conditionally."

Follow-up questions

  • Why are hooks order-dependent?
  • Functional vs direct setter — when does the difference matter?
  • What does the lazy initializer save?
  • How does React skip a re-render when the new value equals the old?

Common mistakes

  • Conditional hooks — slot misalignment.
  • Mutating state object instead of replacing it.
  • Using the direct setter when functional is needed (multiple updates in one handler).
  • Heavy work in the initializer expression instead of a lazy function.

Performance considerations

  • Bail-out on Object.is-equal is cheap. Batching collapses many updates into one render. Lazy initializers avoid one-time cost.

Edge cases

  • Calling setState with the same value — bail-out, no render.
  • Setting state during render → infinite loop (unless inside a useEffect or unconditional during render with strict guard).
  • Setting state on unmounted component — React warns; cleanup needed.

Real-world examples

  • Any local component state.
  • Counter / form / toggle / modal-open patterns.

Senior engineer discussion

Seniors use the functional setter when correctness across batched updates matters, lazy initializers for expensive defaults, and don't mutate state — they understand that the slot model requires stable hook order.

Related questions