How hooks are stored and how
setStateturns into a render. Read fromrenderWithHooks,mountWorkInProgressHook,updateWorkInProgressHook,mountState,dispatchSetStateInternal,updateReducerImpl,mountMemo,mountRefandpushSimpleEffectin the React 19.2.5 build, plus react.dev’s Rules of Hooks and Queueing a Series of State Updates. Part of React Internals. It builds on React Fiber (hooks live on the fiber). My own notes and clarifications are marked with> 💬.
Interview Q&A
Where does React store a function component's state?
On its fiber. fiber.memoizedState points to the first hook object,
and each hook points to the next one, forming a linked list in call order.
A component function has no memory of its own. Each render, React walks that
list alongside your hook calls.
Why must hooks be called in the same order every render?
Because the list is matched by position, not by name. The 3rd hook
call gets the 3rd hook object. If a condition skips a call, every hook after
it reads the wrong object: state from one useState shows up in another, or
React throws “Rendered more hooks than during the previous render.”
Why is the setState function stable across renders?
It’s created once on mount: dispatchSetState.bind(null, fiber, queue).
It’s bound to the fiber and the hook’s queue, which never change, so it can be
left out of dependency arrays. In our example, passing setCount to a
memoized child never breaks memoization.
Why does setCount(count + 1) three times only add 1?
Each call enqueues an update whose action is the value count + 1,
computed from the same render’s count. When the queue is processed, each
update replaces the state with that value. An updater function
(setCount(c => c + 1)) is applied to the result of the previous update
instead, so three of them add 3.
What happens if you set state to the same value?
If the fiber has no other pending work, setState computes the new state
eagerly and compares it with Object.is. If it’s equal, React doesn’t
schedule a render at all. Otherwise it renders, and if the result is still
equal, it bails out of the children (The Work Loop).
How a hook call finds its data
useState from the react package is almost empty. It forwards to whatever
dispatcher is currently installed:
// react package, simplified
export function useState(initialState) {
return ReactSharedInternals.H.useState(initialState)
}
renderWithHooks (called from beginWork for function components) installs
the right dispatcher, calls your component, and removes it again:
function renderWithHooks(current, workInProgress, Component, props) {
currentlyRenderingFiber = workInProgress
workInProgress.memoizedState = null // hook list is rebuilt this render
workInProgress.updateQueue = null // effect list is rebuilt this render
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // first render: useState → mountState
: HooksDispatcherOnUpdate // later renders: useState → updateReducer
let children = Component(props)
ReactSharedInternals.H = ContextOnlyDispatcher // hooks called outside render now throw
// … checks that the same number of hooks was called
return children
}
ContextOnlyDispatcheris where the “Invalid hook call. Hooks can only be called inside of the body of a function component” error comes from. Outside a render,Hpoints to a dispatcher whose hooks all throw.
The hook list
Each hook is one object (from mountWorkInProgressHook):
Counter fiber.memoizedState0.next{ current: <button> }.nextdeps [0].nextdeps [0].nextfiber.memoizedState, matched purely by call order.function mountWorkInProgressHook() {
var hook = {
memoizedState: null, // this hook's value (meaning depends on the hook type)
baseState: null, // state before any skipped low-priority updates
baseQueue: null, // skipped updates, to replay later
queue: null, // pending updates (useState/useReducer)
next: null // the next hook
}
null === workInProgressHook
? (currentlyRenderingFiber.memoizedState = workInProgressHook = hook) // first hook
: (workInProgressHook = workInProgressHook.next = hook) // append
return workInProgressHook
}
On updates, updateWorkInProgressHook walks two lists in lockstep: the
current fiber’s hooks (currentHook) and the new work-in-progress hooks. It
clones each current hook as it goes. If the current list runs out early, it
throws:
if (null === nextCurrentHook) {
throw Error("Rendered more hooks than during the previous render.")
}
What memoizedState holds, per hook type (from mountState, mountRef,
mountMemo, mountEffectImpl):
| Hook | hook.memoizedState |
Extra |
|---|---|---|
useState / useReducer |
the current state | hook.queue = { pending, lanes, dispatch, lastRenderedReducer, lastRenderedState } |
useRef |
{ current: initialValue }, the same object forever |
– |
useMemo |
[value, deps] |
recomputed when areHookInputsEqual(deps, prevDeps) fails |
useCallback |
[callback, deps] |
same as useMemo, without calling it |
useEffect / useLayoutEffect |
an effect { tag, create, deps, inst: { destroy }, next } |
also pushed onto fiber.updateQueue.lastEffect, a circular list the commit walks |
useContext |
no hook object. It reads from the context stack and records a dependency on fiber.dependencies |
that’s why it’s the one hook that could be called conditionally (use(Context) is allowed to be) |
areHookInputsEqual compares deps element by element with Object.is:
for (var i = 0; i < prevDeps.length && i < nextDeps.length; i++)
if (!objectIs(nextDeps[i], prevDeps[i])) return false
return true
Why conditional hooks break, concretely
function Form({ showName }) {
if (showName) {
const [name, setName] = useState('Ada') // hook #1 (only sometimes)
}
const [age, setAge] = useState(36) // hook #1 or #2
useEffect(() => { document.title = age }) // hook #2 or #3
}
useState('Ada')useState(36)useEffect(…)- render phase
useState('Ada') → mountWorkInProgressHook()First render (mount). Each hook call appends a new object to the list.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Form) › updateFunctionComponent › renderWithHooks › Form() › mountState › mountWorkInProgressHook - render phase
useState(36) → mountWorkInProgressHook()Second call → hook #2.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Form) › updateFunctionComponent › renderWithHooks › Form() › mountState › mountWorkInProgressHook - render phase
useEffect(…) → mountWorkInProgressHook()Third call → hook #3. Notice the list stores no names: only order.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Form) › updateFunctionComponent › renderWithHooks › Form() › mountEffect › mountWorkInProgressHook - render phase
Form({ showName: false })Render 2:showNameis false, so the firstuseStateis skipped. The hook list from render 1 is still there.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Form) › updateFunctionComponent › renderWithHooks › Form() - render phase
useState(36) → updateWorkInProgressHook() → takes hook #1The first call takes hook #1, because that’s the next one in the list. It stores'Ada'.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Form) › updateFunctionComponent › renderWithHooks › Form() › updateReducer › updateWorkInProgressHook - render phase
useEffect(…) → updateWorkInProgressHook() → takes hook #2The effect call takes hook #2, which is a state hook. It reads garbage as effect deps.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Form) › updateFunctionComponent › renderWithHooks › Form() › updateEffect › updateWorkInProgressHook - render phase
console.error(…)Hook #3 is never used. React warns in development. That’s the whole reason for the Rules of Hooks: hooks are matched by call order.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Form) › updateFunctionComponent › renderWithHooks › Form()
| Render | showName |
Calls | List walked | Result |
|---|---|---|---|---|
| 1 (mount) | true |
useState, useState, useEffect | builds [ 'Ada', 36, effect ] |
ok |
| 2 | false |
useState(36), useEffect | takes hook #1 ('Ada') for age, hook #2 (36) as the effect |
age === 'Ada'. The effect hook reads a state hook object (crash or nonsense). React warns: “React has detected a change in the order of Hooks” |
react.dev’s rule: “Don’t call Hooks inside loops, conditions, nested
functions, or try/catch/finally blocks.”
setState: from call to render
mountState creates the queue and the bound dispatcher:
queue.pending points at the last update, and last.next is the first. Appending is O(1).function mountState(initialState) {
const hook = mountStateImpl(initialState) // memoizedState = initialState (or initializer())
const queue = hook.queue
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue)
queue.dispatch = dispatch
return [hook.memoizedState, dispatch]
}
Calling dispatch(action) → dispatchSetState picks a lane
(requestUpdateLane, see Scheduler, Lanes and Batching) → then
dispatchSetStateInternal, simplified from the source:
function dispatchSetStateInternal(fiber, queue, action, lane) {
const update = { lane, action, hasEagerState: false, eagerState: null, next: null }
// EAGER BAILOUT: only if this fiber has no other pending updates
if (fiber.lanes === 0 && (fiber.alternate === null || fiber.alternate.lanes === 0)) {
const currentState = queue.lastRenderedState
const eagerState = queue.lastRenderedReducer(currentState, action) // run the update NOW
update.hasEagerState = true
update.eagerState = eagerState
if (Object.is(eagerState, currentState)) {
enqueueUpdate(fiber, queue, update, NoLane) // keep it, but DON'T schedule a render
return false
}
}
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane) // stash update, walk up to the root
if (root !== null) scheduleUpdateOnFiber(root, fiber, lane) // mark lanes, schedule root
return true
}
Stashed updates are linked into queue.pending when the next render starts
(finishQueueingConcurrentUpdates). The pending queue is a circular linked
list. queue.pending points to
the last update, and last.next is the first, so appending is O(1) and
reading from the start is O(1).
Processing the queue during render
When the component renders again, the update dispatcher’s useState calls
updateReducer(basicStateReducer). basicStateReducer is:
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action
}
updateReducerImpl moves queue.pending into the hook’s baseQueue, then
loops over the updates:
newState = hook.baseState
for each update in baseQueue:
if update.lane is NOT in the lanes being rendered:
skip it: copy it into a new baseQueue (and keep newBaseState frozen at this point)
else:
newState = update.hasEagerState ? update.eagerState : reducer(newState, update.action)
hook.memoizedState = newState
if newState is not Object.is-equal to the old state: didReceiveUpdate = true
The “skip and keep” branch is what makes priorities safe. A sync render in the middle of a pending transition applies only the sync updates, but it keeps the transition updates and everything queued after them in
baseQueue. When the transition renders, it replays them all in the original order frombaseState. That’s why updater functions can run more than once, and why they must be pure.
Queue processing, step by step
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1) // action: 1
setCount((c) => c + 1) // action: c => c + 1
setCount(42) // action: 42
setCount((c) => c * 2) // action: c => c * 2
}
setCount(count + 1)setCount(c => c + 1)setCount(42)setCount(c => c * 2)- event
dispatchSetState(fiber, queue, 1) · eager: 0 → 1The fiber has no pending work, so React computes the new state eagerly: 1 ≠ 0, so a render is needed.stack: dispatchDiscreteEvent › dispatchEvent › onClick › handleClick › dispatchSetState › dispatchSetStateInternal - schedule
scheduleUpdateOnFiber(root, fiber, SyncLane) → queueMicrotaskA render is scheduled for later, in a microtask. Nothing renders now.stack: dispatchDiscreteEvent › dispatchEvent › onClick › handleClick › dispatchSetState › scheduleUpdateOnFiber › ensureRootIsScheduled - event
dispatchSetState(fiber, queue, c => c + 1)The fiber now has pending lanes, so no eager computation: the update is just appended.queue.pendingalways points at the last update.stack: dispatchDiscreteEvent › dispatchEvent › onClick › handleClick › dispatchSetState › dispatchSetStateInternal - event
dispatchSetState(fiber, queue, 42)Appended. The microtask is already scheduled.stack: dispatchDiscreteEvent › dispatchEvent › onClick › handleClick › dispatchSetState › dispatchSetStateInternal - event
dispatchSetState(fiber, queue, c => c * 2)Appended. The handler returns. Four updates are waiting; nothing has rendered.stack: dispatchDiscreteEvent › dispatchEvent › onClick › handleClick › dispatchSetState › dispatchSetStateInternal - render phase
Counter() → useState → updateReducer(basicStateReducer)The microtask renders Counter once.updateReducerfolds the queue, starting from the base state 0.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer - render phase
u1: hasEagerState → 1u1 already has its eager result: 1.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer - render phase
u2: basicStateReducer(1, c => c + 1) → 2An updater function receives the latest state: 1 + 1 = 2.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer - render phase
u3: basicStateReducer(2, 42) → 42A plain value replaces whatever came before: 42.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer - render phase
u4: basicStateReducer(42, c => c * 2) → 8442 × 2 = 84. One render, all four updates, in order.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer
setState only queues. The render folds every queued update in order.| # | Call | Eager? | Queue after | Render scheduled? |
|---|---|---|---|---|
| 1 | setCount(1) |
yes (fiber had no lanes). eagerState = 1 ≠ 0 |
[u1] |
yes (SyncLane, microtask) |
| 2 | setCount(c => c + 1) |
no (fiber now has lanes) |
[u1, u2] |
already scheduled |
| 3 | setCount(42) |
no | [u1, u2, u3] |
– |
| 4 | setCount(c => c * 2) |
no | [u1, u2, u3, u4] |
– |
In the render, updateReducer folds the queue:
| Update | Action | State |
|---|---|---|
| start | – | 0 |
| u1 | 1 (eager state) |
1 |
| u2 | c => c + 1 |
2 |
| u3 | 42 |
42 |
| u4 | c => c * 2 |
84 |
One render, and count === 84. react.dev’s summary: React “waits until
all code in the event handlers has run before processing your state
updates.”
Rules and caveats
- Call hooks at the top level, in the same order, every render. The hook list is positional.
- Setters are stable, state values are snapshots.
countinside a handler is the value from the render that created the handler. Use updater functions when the next state depends on the previous one. - Setting equal state is (nearly) free. The eager
Object.ischeck skips scheduling when possible. When it can’t run early, the render still bails out of children. - Updater functions and reducers must be pure. They can be replayed when lanes are rebased, and Strict Mode calls them twice.
useRefis just a hook whosememoizedStateis a mutable object React never looks inside. Changing.currentnever schedules anything.- Deps are compared with
Object.isper element. A new object or array in deps means “changed” every render.
Sources
- React 19.2.5 source:
renderWithHooks,mountWorkInProgressHook,updateWorkInProgressHook,mountState,dispatchSetState,dispatchSetStateInternal,basicStateReducer,updateReducerImpl,mountMemo,updateMemo,mountRef,mountEffectImpl,pushSimpleEffect,areHookInputsEqual - React source:
ReactFiberHooks.js - react.dev: Rules of Hooks
- react.dev: Queueing a Series of State Updates
Primary source: github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js