Hooks on Mount
A function component's first render runs inside renderWithHooks. It points the global hooks dispatcher (ReactSharedInternals.H) at the mount implementations, then calls your…
- Published
- Reading time
- 12 min read
Where we are: beginWork reached a FunctionComponent fiber with current === null (first mount) and called updateFunctionComponent, which calls renderWithHooks.1 Everything below happens inside your component's function call, during the render phase.
1. renderWithHooks: setting the stage
useState in the react package does almost nothing. It reads ReactSharedInternals.H and forwards the call to it.23 The react package has no hooks logic of its own. The renderer (react-reconciler) installs a dispatcher object before it calls your component and removes it afterwards. See Bootstrap for why it's split this way: one react package can serve DOM, Native and test renderers.
renderWithHooks does five things (simplified):4
export function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
renderLanes = nextRenderLanes;
currentlyRenderingFiber = workInProgress; // module-level "who am I rendering"
workInProgress.memoizedState = null; // hook list will be rebuilt
workInProgress.updateQueue = null; // effect list will be rebuilt
workInProgress.lanes = NoLanes;
ReactSharedInternals.H =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
let children = Component(props, secondArg); // <-- your code runs here
if (didScheduleRenderPhaseUpdateDuringThisPass) {
children = renderWithHooksAgain(/* ... */); // setState during render → loop
}
// DEV + StrictMode: renderWithHooksAgain() once more (double render)
finishRenderingHooks(current, workInProgress, Component);
return children;
}The dispatcher decision is current === null || current.memoizedState === null. The second half of that condition matters. A fiber can have a current and still hold no hooks state, for example a component that suspended before it ever committed hooks. React treats that as a mount too. In DEV there is a third variant, HooksDispatcherOnMountWithHookTypesInDEV, which helps produce the "change in the order of Hooks" warning.4
The two dispatchers are plain object literals that map each public hook to an implementation.56
The last three rows are the same function in both columns, and that's a clue. They don't take a slot in the hook list (more on this below).
After your function returns, finishRenderingHooks sets the dispatcher to ContextOnlyDispatcher. Every hook in that dispatcher is throwInvalidHookError.78 That's why calling useState inside an event handler or a setTimeout throws "Invalid hook call". It's not a lint rule. Outside a render, the dispatcher really is a minefield. finishRenderingHooks also checks currentHook !== null && currentHook.next !== null and throws "Rendered fewer hooks than expected" if you returned early.7
2. The Hook object and the list
export type Hook = {
memoizedState: any, // what this hook "is": state value, effect object, [value, deps], ref...
baseState: any, // state before the first skipped update (see page 11)
baseQueue: Update<any, any> | null, // updates skipped in a previous render, still to apply
queue: any, // UpdateQueue for state/reducer hooks, null otherwise
next: Hook | null,
};mountWorkInProgressHook allocates one of these and links it onto the end of the list:10
function mountWorkInProgressHook(): Hook {
const hook = { memoizedState: null, baseState: null, baseQueue: null, queue: null, next: null };
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = hook; // first hook
} else {
workInProgressHook = workInProgressHook.next = hook; // append
}
return workInProgressHook;
}workInProgressHook is a module-level cursor, so the file has no index variable at all. For a component like this:
function Counter() {
const [count, setCount] = useState(0);
const ref = useRef(null);
useEffect(() => { document.title = count; }, [count]);
const doubled = useMemo(() => count * 2, [count]);
// ...
}the fiber ends up looking like this:
flowchart LR
F["Fiber (Counter)<br/>memoizedState"] --> H1["Hook #1 useState<br/>memoizedState: 0<br/>queue: {pending, dispatch, ...}"]
H1 -->|next| H2["Hook #2 useRef<br/>memoizedState: {current: null}"]
H2 -->|next| H3["Hook #3 useEffect<br/>memoizedState: Effect"]
H3 -->|next| H4["Hook #4 useMemo<br/>memoizedState: [0, [0]]"]
F2["Fiber.updateQueue<br/>{lastEffect, events, stores, memoCache}"] --> E1["Effect (circular list)<br/>tag: HasEffect|Passive"]
H3 -.same object.-> E13. useState / useReducer: the queue and the bound dispatch
mountState calls mountStateImpl, which (simplified):1112
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState(); // lazy initializer, only on mount
// DEV StrictMode: call it a second time and throw the result away
}
hook.memoizedState = hook.baseState = initialState;
const queue = {
pending: null, // circular list of not-yet-processed updates
lanes: NoLanes, // for transition entanglement
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState, // used by the eager-state bailout in setState
};
hook.queue = queue;
// mountState:
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];Three things are worth internalising:
setStateis a bound function. It closes over the fiber and the queue, not the component instance or a hook index. When you call it later from an event handler, React already knows where the update goes, without a tree search. See setState.- The setter is stable. It's created once on mount and stored on
queue.dispatch. The update path just returnsqueue.dispatch.13 That's why leavingsetCountout of effect deps is safe. useStateisuseReducerwith a built-in reducer.basicStateReduceristypeof action === 'function' ? action(state) : action.14mountReduceris the same code with your reducer and an optionalinit(initialArg). Its dispatch is bound todispatchReducerActioninstead.15 The one behavioural difference:dispatchReducerActiondoesn't try the eager-state bailout, because your reducer could change between renders.16
4. useEffect: effect objects and flags
mountEffect calls mountEffectImpl(PassiveEffect | PassiveStaticEffect, HookPassive, create, deps). In DEV StrictMode it also adds MountPassiveDevEffect.17
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber.flags |= fiberFlags; // "this fiber has passive work"
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags, // always fires on mount
createEffectInstance(), // { destroy: undefined }
create,
nextDeps,
);
}There are two levels of flags here, and it's easy to mix them up:
- Fiber flags (
PassiveEffect) onfiber.flags. These bubble up assubtreeFlagsin completeWork, so the commit phase can skip whole subtrees that have nothing to do. - Hook effect tags (
HasEffect = 0b0001,Insertion = 0b0010,Layout = 0b0100,Passive = 0b1000) on eachEffect.tag.19 At commit time,commitHookEffectListMount(HookPassive | HookHasEffect, fiber)walks the effect ring and runs only the effects whose tag contains all of those bits.20
pushSimpleEffect builds {tag, create, deps, inst, next} and pushEffectImpl appends it to a circular list on fiber.updateQueue.lastEffect, creating the FunctionComponentUpdateQueue ({lastEffect, events, stores, memoCache}) lazily.212223 The list is circular with a pointer to the last element, so append is O(1) and lastEffect.next is the head. React uses this trick all over (update queues too).
So each effect lives in two places. The hook's memoizedState points at the Effect object, so the next render can read the previous deps. The fiber's updateQueue ring is what the commit phase iterates. The inst object ({destroy}) is shared across renders for the effect's whole lifetime. That's where the cleanup function returned by your effect is stored.24
useLayoutEffect and useInsertionEffect take the same path with HookLayout/UpdateEffect and HookInsertion respectively. Only the timing differs, and the commit phase owns the timing.
5. useRef, useMemo, useCallback
These are almost boring, which is the point:
function mountRef(initialValue) {
const hook = mountWorkInProgressHook();
const ref = { current: initialValue };
hook.memoizedState = ref; // the same object forever; updateRef just returns it
return ref;
}mountMemo calls nextCreate() and stores [nextValue, nextDeps]. In StrictMode DEV it calls nextCreate() a second time to surface impure factories.27 mountCallback stores [callback, nextDeps] without calling anything.28 useCallback(fn, deps) really is useMemo(() => fn, deps) minus one closure.
6. Hooks that don't take a slot: useContext, use, useMemoCache
useContext is readContext in both dispatchers.5 readContext reads context._currentValue (pushed by the nearest Provider during beginWork). It then appends a {context, memoizedValue, next} item to fiber.dependencies. It never calls mountWorkInProgressHook.2930 Context dependencies are a separate list that context propagation scans when a Provider's value changes. A consequence: useContext doesn't strictly need a stable call order. The rule still applies to it for consistency and future-proofing, but the implementation would survive a conditional useContext.
use(usable): for a thenable it calls useThenable, which uses its own counter (thenableIndexCounter) into a thenableState array instead of the hook list. For a context it calls readContext.3132 That's why use is the one hook you may call inside if and loops: it's indexed by how many thenables you've awaited so far, and that's reset on replay. One subtle bit: if a component suspends on use before it reaches any hooks, useThenable re-picks the mount vs update dispatcher for the hooks that come after it. A replay may then see an empty WIP list.32
7. Why the rules of hooks exist
Put the pieces together. On update, updateWorkInProgressHook doesn't look up "the count state". It takes currentHook.next, the next node in the previous render's list, and clones it.33 The n-th hook call in this render is paired with the n-th node from the last committed render. That's all there is to it.
function Bad({ show }) {
if (show) useState('a'); // hook #1 only when show
const [n] = useState(0); // hook #1 or #2 depending on show!
}When show flips from true to false, useState(0) gets paired with the node that held 'a', so it returns 'a'. If the kinds differ (say an effect node gets reused for state), the queue is null and you get "Should have a queue. You are likely calling Hooks conditionally".13 If the new render calls more hooks than the old list has, updateWorkInProgressHook throws "Rendered more hooks than during the previous render".33 With fewer, you get the finishRenderingHooks error from section 1. The official rule text is on react.dev.34
Why design it this way instead of keys? Positional identity keeps the API key-free (useState(0), not useState('count', 0)), composes freely through custom hooks without namespacing, and lets lookup be a pointer bump instead of a map lookup. The cost is a rule a linter has to enforce. The React team considers that a good trade.
8. StrictMode: the double invoke, precisely
In DEV, if the fiber has StrictLegacyMode in its mode, renderWithHooks calls your component twice. The second call goes through renderWithHooksAgain.435 Precisely:
- The first call runs with the mount dispatcher and
shouldDoubleInvokeUserFnsInHooksDEV = true. Inside it,mountState's lazy initializer,mountReducer'sinit, andmountMemo's factory are each called twice, and the second result is thrown away.111527 - The second call uses
HooksDispatcherOnRerender.renderWithHooksAgainresetscurrentHook/workInProgressHooktonullbut keepsworkInProgress.memoizedState. SoupdateWorkInProgressHookwalks and reuses the hook list the first call built.3533 The ref object, the state, and the memoized value are identical between the two calls. Only the component body is re-executed. - Effects aren't run twice during render. StrictMode's "mount → unmount → mount" effect simulation happens at commit time (
doubleInvokeEffectsInDEVIfNecessary, driven byMountPassiveDevEffect/MountLayoutDevEffect).36
With the React Compiler
The compiler rewrites your component so it starts with const $ = _c(N), where _c is imported from react/compiler-runtime. On React 19, _c is useMemoCache: ReactCompilerRuntime.js re-exports useMemoCache as c, and the runtime package uses React.__COMPILER_RUNTIME.c when present. It only falls back to a useMemo-based polyfill for React < 19.3738 useMemoCache dispatches through ReactSharedInternals.H like any other hook.39 The full transform is in React Compiler.
There is no mountMemoCache. A single useMemoCache function sits in all three dispatchers, and it doesn't touch the hook linked list at all.405 Simplified:
function useMemoCache(size) {
let updateQueue = currentlyRenderingFiber.updateQueue;
let memoCache = updateQueue?.memoCache;
if (memoCache == null) {
const current = currentlyRenderingFiber.alternate;
const currentMemoCache = current?.updateQueue?.memoCache;
if (currentMemoCache != null) {
memoCache = { // update: copy-on-write clone
data: enableNoCloningMemoCache
? currentMemoCache.data
: currentMemoCache.data.map(array => array.slice()),
index: 0,
};
}
}
if (memoCache == null) memoCache = { data: [], index: 0 }; // mount: brand-new
if (updateQueue === null) {
updateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = updateQueue;
}
updateQueue.memoCache = memoCache;
let data = memoCache.data[memoCache.index];
if (data === undefined) {
data = memoCache.data[memoCache.index] = new Array(size);
for (let i = 0; i < size; i++) data[i] = REACT_MEMO_CACHE_SENTINEL; // "never computed"
}
memoCache.index++;
return data;
}Where it lives: fiber.updateQueue.memoCache, which is {data: Array<Array<mixed>>, index}. data is an array of cache arrays, one per useMemoCache call in the component. Compiled components normally call it once. index is a cursor that works like the hook-list cursor.2340
On mount, the cache array is allocated with size slots, each filled with REACT_MEMO_CACHE_SENTINEL, which is Symbol.for('react.memo_cache_sentinel').4041 Compiled code checks if ($[0] === Symbol.for("react.memo_cache_sentinel")) for "computed once" values, and if ($[0] !== props.x) for dependency checks. On the first render, every check fails and every block computes. The compiler is "free" on mount only in the sense that it adds one array allocation and some comparisons.
Gotchas worth knowing:
- Because it uses
updateQueuerather than a hook slot,useMemoCachedoesn't shift the positions of your other hooks. The compiler still always emits it unconditionally as the first statement. renderWithHookssetsworkInProgress.updateQueue = nullat the start of every render.4 That's why the update path has to pull the cache fromalternate. See Hooks on Update for the copy-on-write and bailout behaviour.- On a render-phase re-render (and the StrictMode second pass),
resetFunctionComponentUpdateQueueonly resetsmemoCache.index = 0and keeps the arrays.42 The second pass sees a warm cache. - In DEV, if
ignorePreviousDependenciesis set (Fast Refresh swapped the component type), the cache array is reallocated. A differentsizeon the same call logs an error.40
Comparison with manual memoization: a hand-written useMemo costs one Hook node plus a [value, deps] tuple per call. The compiler's cache costs one flat array per component, however many values it memoizes.
Summary
A function component's first render runs inside renderWithHooks, which installs the mount dispatcher on ReactSharedInternals.H, calls your function, and then swaps in a dispatcher that throws. Each hook call appends one Hook node to a linked list on fiber.memoizedState, and effects also go on a circular ring in fiber.updateQueue. Nothing is named or keyed: a hook's identity is its position in that list, and the rules of hooks follow directly from it. The compiler's useMemoCache sidesteps the list entirely and keeps its cache on fiber.updateQueue.memoCache. Hooks on Update shows how the next render walks this list in step.
Key takeaways
renderWithHooks: picksHooksDispatcherOnMountwhencurrent === null || current.memoizedState === null, then installsContextOnlyDispatcher, so hooks outside render throw.mountWorkInProgressHook: appends a node using a module-level cursor; there is no index variable.mountState:useStateisuseReducerwithbasicStateReducer, and its setter isdispatchSetStatebound to the fiber and queue, created once and stable.mountEffectImpl: sets a fiber flag (PassiveEffect) that bubbles tosubtreeFlags, plus hook tags (HookHasEffect | HookPassive) that the commit phase filters on.useRef: a hook slot holding a mutable object; writingref.currentschedules nothing.useContextanduse: neither takes a hook slot;readContextwrites tofiber.dependenciesandusehas its own thenable counter (the rules of hooks still apply touseContext).- Rules of hooks: on update the n-th call is paired with the n-th node from the last render, so conditional calls read the wrong state or throw.
- StrictMode: the second call reuses the hook list from the first, so only the body and initializer functions run twice.
useMemoCache: one flat array per component, filled withREACT_MEMO_CACHE_SENTINELon mount.
Footnotes
-
updateFunctionComponentcallsrenderWithHooks— packages/react-reconciler/src/ReactFiberBeginWork.js#L1453-L1562 (opens in a new tab) ↩ -
resolveDispatcherreadsReactSharedInternals.H— packages/react/src/ReactHooks.js#L24-L42 (opens in a new tab) ↩ -
public
useState— packages/react/src/ReactHooks.js#L66-L71 (opens in a new tab) ↩ -
renderWithHooks— packages/react-reconciler/src/ReactFiberHooks.js#L505-L634 (opens in a new tab) ↩ ↩2 ↩3 ↩4 -
HooksDispatcherOnMount— packages/react-reconciler/src/ReactFiberHooks.js#L3926-L3952 (opens in a new tab) ↩ ↩2 ↩3 -
HooksDispatcherOnUpdate— packages/react-reconciler/src/ReactFiberHooks.js#L3954-L3980 (opens in a new tab) ↩ -
finishRenderingHooks(resets dispatcher, "Rendered fewer hooks" check) — packages/react-reconciler/src/ReactFiberHooks.js#L636-L753 (opens in a new tab) ↩ ↩2 -
ContextOnlyDispatcher(every hook =throwInvalidHookError) — packages/react-reconciler/src/ReactFiberHooks.js#L3898-L3924 (opens in a new tab) ↩ -
Hooktype — packages/react-reconciler/src/ReactFiberHooks.js#L197-L203 (opens in a new tab) ↩ -
mountWorkInProgressHook— packages/react-reconciler/src/ReactFiberHooks.js#L983-L1002 (opens in a new tab) ↩ -
mountStateImpl— packages/react-reconciler/src/ReactFiberHooks.js#L1920-L1946 (opens in a new tab) ↩ ↩2 -
mountState(bindsdispatchSetState) — packages/react-reconciler/src/ReactFiberHooks.js#L1948-L1960 (opens in a new tab) ↩ -
updateReducerImpl("Should have a queue" error; returnsqueue.dispatch) — packages/react-reconciler/src/ReactFiberHooks.js#L1316-L1589 (opens in a new tab) ↩ ↩2 -
basicStateReducer— packages/react-reconciler/src/ReactFiberHooks.js#L1265-L1268 (opens in a new tab) ↩ -
mountReducer— packages/react-reconciler/src/ReactFiberHooks.js#L1270-L1305 (opens in a new tab) ↩ ↩2 -
dispatchReducerAction(no eager-state path) — packages/react-reconciler/src/ReactFiberHooks.js#L3583-L3624 (opens in a new tab) ↩ -
mountEffect— packages/react-reconciler/src/ReactFiberHooks.js#L2697-L2719 (opens in a new tab) ↩ -
mountEffectImpl— packages/react-reconciler/src/ReactFiberHooks.js#L2640-L2655 (opens in a new tab) ↩ -
commitHookEffectListMount((effect.tag & flags) === flags) — packages/react-reconciler/src/ReactFiberCommitEffects.js#L141-L247 (opens in a new tab) ↩ -
pushSimpleEffect— packages/react-reconciler/src/ReactFiberHooks.js#L2588-L2603 (opens in a new tab) ↩ -
pushEffectImpl(circular list onupdateQueue.lastEffect) — packages/react-reconciler/src/ReactFiberHooks.js#L2605-L2622 (opens in a new tab) ↩ -
FunctionComponentUpdateQueuetype andcreateFunctionComponentUpdateQueue— packages/react-reconciler/src/ReactFiberHooks.js#L249-L254 (opens in a new tab), #L1075-L1082 (opens in a new tab) ↩ ↩2 -
EffectInstance/Effecttypes — packages/react-reconciler/src/ReactFiberHooks.js#L219-L229 (opens in a new tab) ↩ -
mountRef— packages/react-reconciler/src/ReactFiberHooks.js#L2628-L2633 (opens in a new tab) ↩ -
updateRef— packages/react-reconciler/src/ReactFiberHooks.js#L2635-L2638 (opens in a new tab) ↩ -
mountMemo— packages/react-reconciler/src/ReactFiberHooks.js#L2944-L2961 (opens in a new tab) ↩ ↩2 -
mountCallback— packages/react-reconciler/src/ReactFiberHooks.js#L2923-L2928 (opens in a new tab) ↩ -
readContext— packages/react-reconciler/src/ReactFiberNewContext.js#L553-L567 (opens in a new tab) ↩ -
readContextForConsumer(appends tofiber.dependencies) — packages/react-reconciler/src/ReactFiberNewContext.js#L580-L627 (opens in a new tab) ↩ -
use— packages/react-reconciler/src/ReactFiberHooks.js#L1159-L1179 (opens in a new tab) ↩ -
useThenable— packages/react-reconciler/src/ReactFiberHooks.js#L1098-L1157 (opens in a new tab) ↩ ↩2 -
updateWorkInProgressHook— packages/react-reconciler/src/ReactFiberHooks.js#L1004-L1073 (opens in a new tab) ↩ ↩2 ↩3 -
Rules of Hooks — react.dev, https://react.dev/reference/rules/rules-of-hooks (opens in a new tab) ↩
-
renderWithHooksAgain— packages/react-reconciler/src/ReactFiberHooks.js#L791-L857 (opens in a new tab) ↩ ↩2 -
doubleInvokeEffectsOnFiber/doubleInvokeEffectsInDEVIfNecessary— packages/react-reconciler/src/ReactFiberWorkLoop.js#L5328-L5388 (opens in a new tab) ↩ -
react/compiler-runtimere-exportsuseMemoCache as c— packages/react/src/ReactCompilerRuntime.js#L10 (opens in a new tab) ↩ -
react-compiler-runtimec(usesReact.__COMPILER_RUNTIME.c, elseuseMemopolyfill) — compiler/packages/react-compiler-runtime/src/index.ts#L19-L39 (opens in a new tab) ↩ -
public
useMemoCache— packages/react/src/ReactHooks.js#L212-L216 (opens in a new tab) ↩ -
useMemoCache(reconciler) — packages/react-reconciler/src/ReactFiberHooks.js#L1181-L1263 (opens in a new tab) ↩ ↩2 ↩3 ↩4 -
REACT_MEMO_CACHE_SENTINEL— packages/shared/ReactSymbols.js#L41-L43 (opens in a new tab) ↩ -
resetFunctionComponentUpdateQueue— packages/react-reconciler/src/ReactFiberHooks.js#L1084-L1096 (opens in a new tab) ↩