Implement a polyfill for useState
`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.
useState is the simplest hook, but the implementation surfaces the slot model that all hooks share.
Behavior
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.
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:
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:
setCount(c => c + 1);
setCount(c => c + 1); // ends at +2vs:
setCount(count + 1);
setCount(count + 1); // ends at +1 — both saw stale countImplement 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:
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.