Must knowReact Internals · 05

Hooks Under the Hood

The hook linked list, why the Rules of Hooks exist, the setState update queue and the eager bailout.

This section:trigger→schedule→render→commit→effects
On this page · 6 sections
  1. Interview Q&A
  2. How a hook call finds its data
  3. The hook list
  4. setState: from call to render
  5. Queue processing, step by step
  6. Rules and caveats

How hooks are stored and how setState turns into a render. Read from renderWithHooks, mountWorkInProgressHook, updateWorkInProgressHook, mountState, dispatchSetStateInternal, updateReducerImpl, mountMemo, mountRef and pushSimpleEffect in 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
}

ContextOnlyDispatcher is where the “Invalid hook call. Hooks can only be called inside of the body of a function component” error comes from. Outside a render, H points to a dispatcher whose hooks all throw.

The hook list

Each hook is one object (from mountWorkInProgressHook):

Counter fiber.memoizedState
→
#1useState0.next
→
#2useRef{ current: <button> }.next
→
#3useLayoutEffectdeps [0].next
→
#4useEffectdeps [0].next
→
null
Hooks live in a linked list on fiber.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
}
Render 1 · showName = true⚠ React has detected a change in the order of Hooks
hooks called (in order)
useState('Ada')
useState(36)
useEffect(…)
fiber.memoizedState (hook list)
#1 state'Ada'
#2 state36
#3 effectdeps: none
what the component gets
name = 'Ada'age = 36effect ✓
  1. render phaseuseState('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
  2. render phaseuseState(36) → mountWorkInProgressHook()Second call → hook #2.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Form) › updateFunctionComponent › renderWithHooks › Form() › mountState › mountWorkInProgressHook
  3. render phaseuseEffect(…) → 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
  4. render phaseForm({ showName: false })Render 2: showName is false, so the first useState is skipped. The hook list from render 1 is still there.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Form) › updateFunctionComponent › renderWithHooks › Form()
  5. render phaseuseState(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
  6. render phaseuseEffect(…) → 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
  7. render phaseconsole.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()
current stepcreatedupdated / movedokwrong

0 / 0
Hooks are matched purely by call order. Skip one, and every later call reads someone else’s slot.
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:

u1u2u3queue.pending →.next
The pending queue is circular: 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 from baseState. 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
}
handleClick()
setCount(count + 1)
setCount(c => c + 1)
setCount(42)
setCount(c => c * 2)
hook.queue (circular)
u1action: 1
u2action: c => c + 1
u3action: 42
u4action: c => c * 2
queue.pending = null
scheduling & state
microtask: nonerenders: 0
count
0
  1. eventdispatchSetState(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
  2. schedulescheduleUpdateOnFiber(root, fiber, SyncLane) → queueMicrotaskA render is scheduled for later, in a microtask. Nothing renders now.stack: dispatchDiscreteEvent › dispatchEvent › onClick › handleClick › dispatchSetState › scheduleUpdateOnFiber › ensureRootIsScheduled
  3. eventdispatchSetState(fiber, queue, c => c + 1)The fiber now has pending lanes, so no eager computation: the update is just appended. queue.pending always points at the last update.stack: dispatchDiscreteEvent › dispatchEvent › onClick › handleClick › dispatchSetState › dispatchSetStateInternal
  4. eventdispatchSetState(fiber, queue, 42)Appended. The microtask is already scheduled.stack: dispatchDiscreteEvent › dispatchEvent › onClick › handleClick › dispatchSetState › dispatchSetStateInternal
  5. eventdispatchSetState(fiber, queue, c => c * 2)Appended. The handler returns. Four updates are waiting; nothing has rendered.stack: dispatchDiscreteEvent › dispatchEvent › onClick › handleClick › dispatchSetState › dispatchSetStateInternal
  6. render phaseCounter() → useState → updateReducer(basicStateReducer)The microtask renders Counter once. updateReducer folds the queue, starting from the base state 0.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer
  7. render phaseu1: hasEagerState → 1u1 already has its eager result: 1.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer
  8. render phaseu2: 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
  9. render phaseu3: basicStateReducer(2, 42) → 42A plain value replaces whatever came before: 42.stack: processRootScheduleInMicrotask › performSyncWorkOnRoot › performWorkOnRoot › renderRootSync › workLoopSync › performUnitOfWork › beginWork(Counter) › updateFunctionComponent › renderWithHooks › Counter() › updateReducer
  10. render phaseu4: 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
current stepcreatedreused / keptupdated / movedcompletedok

0 / 0
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

Sources

Primary source: github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js