Hooks on Update
On a re-render, renderWithHooks installs HooksDispatcherOnUpdate. Each hook call then clones the matching node from the committed (current) fiber's hook list onto the…
- Published
- Reading time
- 10 min read
This page assumes Hooks on Mount: the Hook shape, the effect ring on fiber.updateQueue, and how the dispatcher is chosen. The trigger for all of this is a setState that queued an update and scheduled a render. See setState.
1. updateWorkInProgressHook: walking two lists in lockstep
On update, renderWithHooks has already set workInProgress.memoizedState = null.1 So the WIP fiber starts with an empty hook list, and the current fiber (workInProgress.alternate) holds the committed one. Two module-level cursors track progress: currentHook (in the old list) and workInProgressHook (in the new one).2
function updateWorkInProgressHook(): Hook {
// advance the cursor on the committed list
let nextCurrentHook = currentHook === null
? currentlyRenderingFiber.alternate?.memoizedState ?? null
: currentHook.next;
// is there already a WIP hook here? (only during a render-phase re-render)
let nextWorkInProgressHook = workInProgressHook === null
? currentlyRenderingFiber.memoizedState
: workInProgressHook.next;
if (nextWorkInProgressHook !== null) {
workInProgressHook = nextWorkInProgressHook; // reuse it
currentHook = nextCurrentHook;
} else {
if (nextCurrentHook === null) {
throw new Error('Rendered more hooks than during the previous render.');
}
currentHook = nextCurrentHook;
const newHook = { // shallow clone
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue, // SHARED with current!
next: null,
};
// append to WIP list (same as mount)
}
return workInProgressHook;
}(simplified)3
What matters:
- The clone is shallow, and
queueis shared between the current and WIP hooks. That's on purpose.setStateholds a reference to the queue object (bound at mount), and new updates must be visible whichever twin ends up committing. - Cloning keeps the committed tree immutable. If this render is interrupted or thrown away (a higher-priority update arrives, or something suspends),
current's hooks still describe what's on screen. That's the double-buffering idea applied at hook granularity. - The "reuse existing WIP hook" branch only fires when the component re-runs within the same render:
renderWithHooksAgainafter a render-phasesetState, and the StrictMode second pass.4 It resets the cursors but notworkInProgress.memoizedState.
2. updateReducer: from pending ring to new state
updateState is literally updateReducer(basicStateReducer, initialState). initialState is ignored on update.5 updateReducer gets the hook and calls updateReducerImpl(hook, currentHook, reducer).6
2a. Merge queue.pending into baseQueue
let baseQueue = hook.baseQueue; // leftovers skipped in an earlier render
const pendingQueue = queue.pending; // new updates since the last render
if (pendingQueue !== null) {
if (baseQueue !== null) { // splice two circular lists into one
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}Both lists are circular and identified by their last node, so concatenation is two pointer swaps. Note current.baseQueue = .... The merged queue is stored on the current hook too, because queue.pending was just cleared. If this WIP render gets discarded, the updates would otherwise be lost. Writing them onto current means a restarted render finds them again.
2b. Replay, skipping lanes that aren't in this render
let newState = baseState;
let newBaseState = null, newBaseQueueFirst = null, newBaseQueueLast = null;
let update = first;
do {
const updateLane = removeLanes(update.lane, OffscreenLane);
const shouldSkipUpdate = !isSubsetOfLanes(renderLanes, updateLane); // (hidden-tree case elided)
if (shouldSkipUpdate) {
// keep a clone for later; remember the state *before* the first skip
const clone = { lane: updateLane, action: update.action, hasEagerState: update.hasEagerState,
eagerState: update.eagerState, /* ... */ next: null };
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
currentlyRenderingFiber.lanes = mergeLanes(currentlyRenderingFiber.lanes, updateLane);
markSkippedUpdateLanes(updateLane);
} else {
if (newBaseQueueLast !== null) {
// once anything was skipped, every later update is kept too (with NoLane so it
// always applies when the queue is rebased)
newBaseQueueLast = newBaseQueueLast.next = { ...update, lane: NoLane, next: null };
}
newState = update.hasEagerState ? update.eagerState : reducer(newState, update.action);
}
update = update.next;
} while (update !== null && update !== first);(simplified. Optimistic revertLane and gesture branches dropped)89
This is rebasing, and it's the subtle core of concurrent state. Example: the state is 0. A transition queues +1 (TransitionLane), then a click queues *2 (SyncLane).
The urgent render shows an intermediate state that skips the transition. The final state is still as if all updates applied in insertion order, the same result a synchronous React would produce. That's why baseState is "the state before the first skipped update", not "the last rendered state", and why every update after a skipped one is kept and replayed even if it was already applied once. Reducers must be pure, because they can run several times per update.
Skipped lanes are merged back into currentlyRenderingFiber.lanes, and the root-level markSkippedUpdateLanes records them.10 So when this render commits, the fiber still has pending work and the root schedules another render for those lanes.
2c. Eager state and the "did it change?" check
update.hasEagerState is set when dispatchSetState already computed the next state at call time (the eager path, see setState). The reducer then isn't called again. That's only valid because basicStateReducer can't change between renders.
After the loop:
if (newBaseQueueLast === null) newBaseState = newState;
else newBaseQueueLast.next = newBaseQueueFirst; // close the ring
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate(); // didReceiveUpdate = true
}
hook.memoizedState = newState;
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast;
queue.lastRenderedState = newState; // used by the next eager-state checkmarkWorkInProgressReceivedUpdate just sets the module-level didReceiveUpdate = true in ReactFiberBeginWork.js.12 It's the only signal that decides whether the component's output counts, as section 5 shows.
3. updateEffect: deps decide HasEffect
function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const effect = hook.memoizedState;
const inst = effect.inst; // same {destroy} instance as before
if (currentHook !== null && nextDeps !== null) {
const prevDeps = currentHook.memoizedState.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
hook.memoizedState = pushSimpleEffect(hookFlags, inst, create, nextDeps); // NO HasEffect
return;
}
}
currentlyRenderingFiber.flags |= fiberFlags; // PassiveEffect
hook.memoizedState = pushSimpleEffect(HookHasEffect | hookFlags, inst, create, nextDeps);
}Two details people often get wrong:
- The effect is pushed every render, changed or not. The ring on
fiber.updateQueueis rebuilt from scratch each render, becauserenderWithHooksnullsupdateQueue. Unchanged effects go in withoutHasEffect, socommitHookEffectListMount(HookPassive | HookHasEffect)skips them.16 They have to be there anyway, because unmount cleanup walks the same ring and needs every effect'sinst.destroy. depsomitted meansnull, andnullmeans "always fire".areHookInputsEqual(next, null)returnsfalse(with a DEV warning if deps became null). Otherwise it compares element-by-element withObject.is, stopping at the shorter length. In DEV it warns if the length changed.17 Objects and functions created during render are never equal to their predecessors. That's the classic "effect runs every render" bug, and it's exactly what the compiler (below) fixes for you.
Only when deps changed does the fiber get PassiveEffect. If no effect on the fiber changed, the commit phase's passive pass can skip it, and skip whole subtrees via subtreeFlags in the commit phase.
4. updateMemo, updateCallback, updateRef
function updateMemo(nextCreate, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState; // [value, deps]
if (nextDeps !== null && areHookInputsEqual(nextDeps, prevState[1])) {
return prevState[0]; // cache hit
}
const nextValue = nextCreate(); // (StrictMode DEV: called twice)
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}updateCallback is the same with [callback, deps], and it returns prevState[0] on a hit.19 That's how a stable callback identity comes about: the new closure you passed in is thrown away. updateRef returns hook.memoizedState unchanged.20
The cache holds exactly one previous value. useMemo is "memoize against the last render", not an LRU. React also reserves the right to drop it. Nothing guarantees it survives, for example when a component suspends on mount.
5. didReceiveUpdate and bailoutHooks
Who reads didReceiveUpdate? beginWork sets it before calling updateFunctionComponent. If oldProps !== newProps (reference inequality) or legacy context changed, it's true. Otherwise it's false, and if checkScheduledUpdateOrContext finds no work for this fiber at the render lanes, React bails out early without calling your function at all.21
If the fiber does have a scheduled update (its lanes overlap renderLanes), the component renders with didReceiveUpdate = false. Then:
updateReducerImplsets ittrueif any state hook produced a new value.finishRenderingHookssets ittrueif a context the fiber read changed (checkIfContextChanged(current.dependencies)).22
Back in updateFunctionComponent:
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}export function bailoutHooks(current, workInProgress, lanes) {
workInProgress.updateQueue = current.updateQueue; // discard the new effect ring
workInProgress.flags &= ~(PassiveEffect | UpdateEffect); // no effects to run
current.lanes = removeLanes(current.lanes, lanes); // the update is "done"
}(simplified, DEV StrictEffects flags dropped)24
So the "late bailout" throws away the effect list it just built and the children it just returned. bailoutOnAlreadyFinishedWork then checks childLanes. If no descendant has work in these lanes, the entire subtree is skipped. Otherwise the children are cloned and traversal continues to whoever does have work.25
flowchart TD
A[beginWork: FunctionComponent] -->|props same, no lanes| B[early bailout: function NOT called]
A -->|has update or new props| C[renderWithHooks: call component]
C --> D{didReceiveUpdate?}
D -->|state changed via Object.is, or context changed, or props changed| E[reconcileChildren]
D -->|no| F[bailoutHooks + bailoutOnAlreadyFinishedWork]
F -->|childLanes empty| G[skip subtree]
F -->|childLanes has work| H[cloneChildFibers, continue]With the React Compiler
Compiled components call useMemoCache(n) (as _c(n)) instead of useMemo/useCallback. On update, useMemoCache runs the same function as on mount (see Hooks on Mount), and the interesting branch is the one that finds the cache on current:26
let memoCache = currentlyRenderingFiber.updateQueue?.memoCache; // null: renderWithHooks reset updateQueue
if (memoCache == null) {
const currentMemoCache = currentlyRenderingFiber.alternate?.updateQueue?.memoCache;
if (currentMemoCache != null) {
memoCache = {
data: enableNoCloningMemoCache
? currentMemoCache.data // share (flag off in OSS)
: currentMemoCache.data.map(array => array.slice()), // copy-on-write clone
index: 0,
};
}
}
// ... attach to WIP updateQueue, return memoCache.data[index++]How the cache reaches the WIP fiber:
renderWithHooksnullsworkInProgress.updateQueue.1 The firstuseMemoCachecall finds no cache on WIP.- It reads
alternate.updateQueue.memoCache, the committed cache, and clones each array withslice().enableNoCloningMemoCacheisfalsein the OSS feature flags.27 - It creates a fresh
FunctionComponentUpdateQueueon the WIP fiber and attaches the clone. Effects pushed later in the same render go into the sameupdateQueueobject. - Compiled code then does
if ($[0] !== props.items) { ...; $[0] = props.items; $[1] = result; } else { result = $[1]; }. It writes only into the clone.
Why copy-on-write? A render can be abandoned (interrupted transition, suspension, an error). If compiled code wrote straight into the committed cache, a discarded render could leave values in it that never reached the screen. The next render would then compare against inputs from a render that never happened. Cloning keeps current's cache consistent with the committed UI, the same reason hooks are cloned in section 1. The cost is one shallow array copy per render of the component. That's cheap next to the work the cache saves.
Interaction with bailouts:
- Early bailout (function not called):
createWorkInProgresscopiescurrent.updateQueueto the WIP fiber,28 so the memo cache simply carries over untouched. - Late bailout:
bailoutHookssetsworkInProgress.updateQueue = current.updateQueue.24 The cloned cache (and anything written into it during this render) is discarded and the committed cache is kept. That's correct: the output was thrown away. - Render-phase re-render / StrictMode second pass:
resetFunctionComponentUpdateQueueresetsmemoCache.index = 0and keeps the arrays.29 So the second pass hits the cache filled by the first.
What changes in practice: in a compiled component, most of the useMemo/useCallback hook nodes and their areHookInputsEqual calls go away. Your useState/useEffect hooks still go through exactly the paths above, because the compiler doesn't change the reconciler. The big difference is downstream. Because the compiler memoizes JSX elements and the props objects it passes to children, children more often see oldProps === newProps and take the early bailout in beginWork instead of re-rendering. Deps arrays for your useEffect are also built from memoized values, so effects fire less often by accident. See With vs Without Compiler and React Compiler.
Summary
On a re-render, renderWithHooks installs HooksDispatcherOnUpdate, and each hook call shallowly clones the matching node from the committed fiber's list. useState and useReducer merge pending updates into a baseQueue and replay it, skipping updates whose lane is not in this render and keeping them for later, so the final state still matches applying every update in insertion order. Whether the output counts comes down to one flag: didReceiveUpdate, set only when state changed by Object.is or a read context changed. If it stays false, bailoutHooks throws the render away after your function already ran. setState covers how those updates got queued in the first place.
Key takeaways
updateWorkInProgressHook: walks the current and WIP lists in lockstep and shallowly clones each hook, sharingqueuesosetStatereaches whichever twin commits.baseQueuemerge: splicingqueue.pendingin is two pointer swaps, and the merged queue is also written tocurrentso a restarted render can find it.- Rebasing: skipped updates and every update after them are kept, and
baseStateis the state before the first skipped update, so reducers must be pure. - Eager state:
hasEagerStatelets replay reuse the valuedispatchSetStatecomputed. updateEffectImpl: every effect is pushed each render, but only those with changed deps getHookHasEffectand setPassiveEffecton the fiber.updateMemo/updateCallback: cache exactly one previous value againstObject.is-compared deps, and React may drop it.bailoutHooks: restoresupdateQueuefrom current and clears effect flags, but keeps the processed hook list.useMemoCacheon update: clones the committed cache arrays copy-on-write, so abandoned renders cannot leak values intocurrent.
Footnotes
-
renderWithHooks(nullsmemoizedState/updateQueue, picks dispatcher) — packages/react-reconciler/src/ReactFiberHooks.js#L505-L634 (opens in a new tab) ↩ ↩2 -
module-level
currentlyRenderingFiber,currentHook,workInProgressHook— packages/react-reconciler/src/ReactFiberHooks.js#L261-L271 (opens in a new tab) ↩ -
updateWorkInProgressHook— packages/react-reconciler/src/ReactFiberHooks.js#L1004-L1073 (opens in a new tab) ↩ -
renderWithHooksAgain— packages/react-reconciler/src/ReactFiberHooks.js#L791-L857 (opens in a new tab) ↩ -
updateState— packages/react-reconciler/src/ReactFiberHooks.js#L1962-L1966 (opens in a new tab) ↩ -
updateReducer— packages/react-reconciler/src/ReactFiberHooks.js#L1307-L1314 (opens in a new tab) ↩ -
updateReducerImpl, merging pending into base queue — packages/react-reconciler/src/ReactFiberHooks.js#L1332-L1359 (opens in a new tab) ↩ -
updateReducerImpl, lane check and skipped-update cloning — packages/react-reconciler/src/ReactFiberHooks.js#L1380-L1446 (opens in a new tab) ↩ -
updateReducerImpl, applying updates and eager state — packages/react-reconciler/src/ReactFiberHooks.js#L1446-L1544 (opens in a new tab) ↩ -
markSkippedUpdateLanes— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2539-L2544 (opens in a new tab) ↩ -
updateReducerImpl,Object.ischeck and write-back — packages/react-reconciler/src/ReactFiberHooks.js#L1546-L1589 (opens in a new tab) ↩ -
markWorkInProgressReceivedUpdate/didReceiveUpdate— packages/react-reconciler/src/ReactFiberBeginWork.js#L3800-L3806 (opens in a new tab), #L317 (opens in a new tab) ↩ ↩2 -
useState reference (caveats on same-value updates) — react.dev, https://react.dev/reference/react/useState (opens in a new tab) ↩
-
updateEffectImpl— packages/react-reconciler/src/ReactFiberHooks.js#L2657-L2695 (opens in a new tab) ↩ -
updateEffect— packages/react-reconciler/src/ReactFiberHooks.js#L2721-L2726 (opens in a new tab) ↩ -
commitHookEffectListMount— packages/react-reconciler/src/ReactFiberCommitEffects.js#L141-L247 (opens in a new tab) ↩ -
areHookInputsEqual— packages/react-reconciler/src/ReactFiberHooks.js#L456-L503 (opens in a new tab) ↩ -
updateMemo— packages/react-reconciler/src/ReactFiberHooks.js#L2963-L2988 (opens in a new tab) ↩ -
updateCallback— packages/react-reconciler/src/ReactFiberHooks.js#L2930-L2942 (opens in a new tab) ↩ -
updateRef— packages/react-reconciler/src/ReactFiberHooks.js#L2635-L2638 (opens in a new tab) ↩ -
beginWorksettingdidReceiveUpdate/ early bailout — packages/react-reconciler/src/ReactFiberBeginWork.js#L4250-L4296 (opens in a new tab) ↩ -
finishRenderingHooks(context-changed check) — packages/react-reconciler/src/ReactFiberHooks.js#L718-L730 (opens in a new tab) ↩ -
updateFunctionComponentlate bailout — packages/react-reconciler/src/ReactFiberBeginWork.js#L1549-L1552 (opens in a new tab) ↩ -
bailoutHooks— packages/react-reconciler/src/ReactFiberHooks.js#L908-L927 (opens in a new tab) ↩ ↩2 -
bailoutOnAlreadyFinishedWork— packages/react-reconciler/src/ReactFiberBeginWork.js#L3826-L3865 (opens in a new tab) ↩ -
useMemoCache— packages/react-reconciler/src/ReactFiberHooks.js#L1181-L1263 (opens in a new tab) ↩ -
enableNoCloningMemoCache = false— packages/shared/ReactFeatureFlags.js#L128 (opens in a new tab) ↩ -
createWorkInProgresscopiesmemoizedState/updateQueue— packages/react-reconciler/src/ReactFiber.js#L389-L390 (opens in a new tab) ↩ -
resetFunctionComponentUpdateQueue— packages/react-reconciler/src/ReactFiberHooks.js#L1084-L1096 (opens in a new tab) ↩