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
React InternalsPart 10 · 11 of 18

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

JavaScript
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

Public hookMountUpdate
useStatemountStateupdateState → updateReducer
useReducermountReducerupdateReducer
useEffectmountEffectupdateEffect
useMemo / useCallbackmountMemo / mountCallbackupdateMemo / updateCallback
useRefmountRefupdateRef
useContextreadContextreadContext
useuseuse
useMemoCache (compiler)useMemoCacheuseMemoCache

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

JavaScript
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,
};

9

mountWorkInProgressHook allocates one of these and links it onto the end of the list:10

JavaScript
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:

JavaScript
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:

3. useState / useReducer: the queue and the bound dispatch

mountState calls mountStateImpl, which (simplified):1112

JavaScript
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:

  1. setState is 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.
  2. The setter is stable. It's created once on mount and stored on queue.dispatch. The update path just returns queue.dispatch.13 That's why leaving setCount out of effect deps is safe.
  3. useState is useReducer with a built-in reducer. basicStateReducer is typeof action === 'function' ? action(state) : action.14 mountReducer is the same code with your reducer and an optional init(initialArg). Its dispatch is bound to dispatchReducerAction instead.15 The one behavioural difference: dispatchReducerAction doesn'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

JavaScript
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,
  );
}

18

There are two levels of flags here, and it's easy to mix them up:

  • Fiber flags (PassiveEffect) on fiber.flags. These bubble up as subtreeFlags in 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 each Effect.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:

JavaScript
function mountRef(initialValue) {
  const hook = mountWorkInProgressHook();
  const ref = { current: initialValue };
  hook.memoizedState = ref;      // the same object forever; updateRef just returns it
  return ref;
}

2526

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.

JavaScript
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's init, and mountMemo's factory are each called twice, and the second result is thrown away.111527
  • The second call uses HooksDispatcherOnRerender. renderWithHooksAgain resets currentHook/workInProgressHook to null but keeps workInProgress.memoizedState. So updateWorkInProgressHook walks 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 by MountPassiveDevEffect/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:

JavaScript
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 updateQueue rather than a hook slot, useMemoCache doesn't shift the positions of your other hooks. The compiler still always emits it unconditionally as the first statement.
  • renderWithHooks sets workInProgress.updateQueue = null at the start of every render.4 That's why the update path has to pull the cache from alternate. See Hooks on Update for the copy-on-write and bailout behaviour.
  • On a render-phase re-render (and the StrictMode second pass), resetFunctionComponentUpdateQueue only resets memoCache.index = 0 and keeps the arrays.42 The second pass sees a warm cache.
  • In DEV, if ignorePreviousDependencies is set (Fast Refresh swapped the component type), the cache array is reallocated. A different size on 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: picks HooksDispatcherOnMount when current === null || current.memoizedState === null, then installs ContextOnlyDispatcher, so hooks outside render throw.
  • mountWorkInProgressHook: appends a node using a module-level cursor; there is no index variable.
  • mountState: useState is useReducer with basicStateReducer, and its setter is dispatchSetState bound to the fiber and queue, created once and stable.
  • mountEffectImpl: sets a fiber flag (PassiveEffect) that bubbles to subtreeFlags, plus hook tags (HookHasEffect | HookPassive) that the commit phase filters on.
  • useRef: a hook slot holding a mutable object; writing ref.current schedules nothing.
  • useContext and use: neither takes a hook slot; readContext writes to fiber.dependencies and use has its own thenable counter (the rules of hooks still apply to useContext).
  • 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 with REACT_MEMO_CACHE_SENTINEL on mount.

← Prev · Index · Next →

Footnotes

  1. updateFunctionComponent calls renderWithHooks — packages/react-reconciler/src/ReactFiberBeginWork.js#L1453-L1562 (opens in a new tab) ↩

  2. resolveDispatcher reads ReactSharedInternals.H — packages/react/src/ReactHooks.js#L24-L42 (opens in a new tab) ↩

  3. public useState — packages/react/src/ReactHooks.js#L66-L71 (opens in a new tab) ↩

  4. renderWithHooks — packages/react-reconciler/src/ReactFiberHooks.js#L505-L634 (opens in a new tab) ↩ ↩2 ↩3 ↩4

  5. HooksDispatcherOnMount — packages/react-reconciler/src/ReactFiberHooks.js#L3926-L3952 (opens in a new tab) ↩ ↩2 ↩3

  6. HooksDispatcherOnUpdate — packages/react-reconciler/src/ReactFiberHooks.js#L3954-L3980 (opens in a new tab) ↩

  7. finishRenderingHooks (resets dispatcher, "Rendered fewer hooks" check) — packages/react-reconciler/src/ReactFiberHooks.js#L636-L753 (opens in a new tab) ↩ ↩2

  8. ContextOnlyDispatcher (every hook = throwInvalidHookError) — packages/react-reconciler/src/ReactFiberHooks.js#L3898-L3924 (opens in a new tab) ↩

  9. Hook type — packages/react-reconciler/src/ReactFiberHooks.js#L197-L203 (opens in a new tab) ↩

  10. mountWorkInProgressHook — packages/react-reconciler/src/ReactFiberHooks.js#L983-L1002 (opens in a new tab) ↩

  11. mountStateImpl — packages/react-reconciler/src/ReactFiberHooks.js#L1920-L1946 (opens in a new tab) ↩ ↩2

  12. mountState (binds dispatchSetState) — packages/react-reconciler/src/ReactFiberHooks.js#L1948-L1960 (opens in a new tab) ↩

  13. updateReducerImpl ("Should have a queue" error; returns queue.dispatch) — packages/react-reconciler/src/ReactFiberHooks.js#L1316-L1589 (opens in a new tab) ↩ ↩2

  14. basicStateReducer — packages/react-reconciler/src/ReactFiberHooks.js#L1265-L1268 (opens in a new tab) ↩

  15. mountReducer — packages/react-reconciler/src/ReactFiberHooks.js#L1270-L1305 (opens in a new tab) ↩ ↩2

  16. dispatchReducerAction (no eager-state path) — packages/react-reconciler/src/ReactFiberHooks.js#L3583-L3624 (opens in a new tab) ↩

  17. mountEffect — packages/react-reconciler/src/ReactFiberHooks.js#L2697-L2719 (opens in a new tab) ↩

  18. mountEffectImpl — packages/react-reconciler/src/ReactFiberHooks.js#L2640-L2655 (opens in a new tab) ↩

  19. Hook effect tags — packages/react-reconciler/src/ReactHookEffectTags.js#L12-L20 (opens in a new tab) ↩

  20. commitHookEffectListMount ((effect.tag & flags) === flags) — packages/react-reconciler/src/ReactFiberCommitEffects.js#L141-L247 (opens in a new tab) ↩

  21. pushSimpleEffect — packages/react-reconciler/src/ReactFiberHooks.js#L2588-L2603 (opens in a new tab) ↩

  22. pushEffectImpl (circular list on updateQueue.lastEffect) — packages/react-reconciler/src/ReactFiberHooks.js#L2605-L2622 (opens in a new tab) ↩

  23. FunctionComponentUpdateQueue type and createFunctionComponentUpdateQueue — packages/react-reconciler/src/ReactFiberHooks.js#L249-L254 (opens in a new tab), #L1075-L1082 (opens in a new tab) ↩ ↩2

  24. EffectInstance / Effect types — packages/react-reconciler/src/ReactFiberHooks.js#L219-L229 (opens in a new tab) ↩

  25. mountRef — packages/react-reconciler/src/ReactFiberHooks.js#L2628-L2633 (opens in a new tab) ↩

  26. updateRef — packages/react-reconciler/src/ReactFiberHooks.js#L2635-L2638 (opens in a new tab) ↩

  27. mountMemo — packages/react-reconciler/src/ReactFiberHooks.js#L2944-L2961 (opens in a new tab) ↩ ↩2

  28. mountCallback — packages/react-reconciler/src/ReactFiberHooks.js#L2923-L2928 (opens in a new tab) ↩

  29. readContext — packages/react-reconciler/src/ReactFiberNewContext.js#L553-L567 (opens in a new tab) ↩

  30. readContextForConsumer (appends to fiber.dependencies) — packages/react-reconciler/src/ReactFiberNewContext.js#L580-L627 (opens in a new tab) ↩

  31. use — packages/react-reconciler/src/ReactFiberHooks.js#L1159-L1179 (opens in a new tab) ↩

  32. useThenable — packages/react-reconciler/src/ReactFiberHooks.js#L1098-L1157 (opens in a new tab) ↩ ↩2

  33. updateWorkInProgressHook — packages/react-reconciler/src/ReactFiberHooks.js#L1004-L1073 (opens in a new tab) ↩ ↩2 ↩3

  34. Rules of Hooks — react.dev, https://react.dev/reference/rules/rules-of-hooks (opens in a new tab) ↩

  35. renderWithHooksAgain — packages/react-reconciler/src/ReactFiberHooks.js#L791-L857 (opens in a new tab) ↩ ↩2

  36. doubleInvokeEffectsOnFiber / doubleInvokeEffectsInDEVIfNecessary — packages/react-reconciler/src/ReactFiberWorkLoop.js#L5328-L5388 (opens in a new tab) ↩

  37. react/compiler-runtime re-exports useMemoCache as c — packages/react/src/ReactCompilerRuntime.js#L10 (opens in a new tab) ↩

  38. react-compiler-runtime c (uses React.__COMPILER_RUNTIME.c, else useMemo polyfill) — compiler/packages/react-compiler-runtime/src/index.ts#L19-L39 (opens in a new tab) ↩

  39. public useMemoCache — packages/react/src/ReactHooks.js#L212-L216 (opens in a new tab) ↩

  40. useMemoCache (reconciler) — packages/react-reconciler/src/ReactFiberHooks.js#L1181-L1263 (opens in a new tab) ↩ ↩2 ↩3 ↩4

  41. REACT_MEMO_CACHE_SENTINEL — packages/shared/ReactSymbols.js#L41-L43 (opens in a new tab) ↩

  42. resetFunctionComponentUpdateQueue — packages/react-reconciler/src/ReactFiberHooks.js#L1084-L1096 (opens in a new tab) ↩