Read OSS

Hooks and the Dispatcher — React's State Machine

Advanced

Prerequisites

  • Article 1: Architecture Overview (fork system and SharedInternals bridge)
  • Article 2: Fiber Data Structure (fiber.memoizedState field)
  • Article 3: Work Loop (when beginWork calls into function components)
  • Familiarity with React hooks as a user (useState, useEffect, useRef, useMemo)

Hooks and the Dispatcher — React's State Machine

Hooks are the API that made function components a first-class citizen in React. But they're also one of the most architecturally interesting pieces of the system — a state machine that relies on call order to maintain identity, bridged across package boundaries through a dispatcher indirection that enables renderer-agnostic hook stubs.

In this article, we'll trace a useState call from the react package through the dispatcher to the reconciler's actual implementation, understand how hook state is stored as a linked list on fiber.memoizedState, examine the mount-vs-update dispatcher swap, and decode the effect system's tag bits.

The Dispatcher Pattern — Renderer-Agnostic Hooks

When you call useState in your component, you're calling a function exported by the react package. But the react package has no dependency on any renderer — it doesn't know about fibers, the work loop, or DOM. How does it work?

The answer is in ReactHooks.js:

function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  // Will result in a null access error if accessed outside render phase.
  return ((dispatcher: any): Dispatcher);
}

export function useState(initialState) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

Every hook stub follows the same pattern: resolve the current dispatcher from ReactSharedInternals.H, then delegate to it. The dispatcher is a plain object implementing the Dispatcher interface — a method for each hook.

sequenceDiagram
    participant C as Component Code
    participant R as react/ReactHooks.js
    participant SI as ReactSharedInternals.H
    participant D as Reconciler Dispatcher

    C->>R: useState(0)
    R->>SI: resolveDispatcher()
    SI-->>R: current dispatcher object
    R->>D: dispatcher.useState(0)
    D-->>R: [state, setState]
    R-->>C: [state, setState]

The property name H is intentionally terse — it's used in hot paths and needs to survive minification predictably. The full set of SharedInternals properties is defined in ReactSharedInternalsClient.js:

Property Purpose
H Hooks dispatcher
A Async dispatcher (for Cache)
T Current transition
S startTransition finish callback
G Gesture transition callback

If H is null (meaning we're outside a render), resolveDispatcher() intentionally doesn't throw — it returns null and lets the subsequent method call produce a null access error. This keeps the function inlined by V8.

Tip: The fork system (from Article 1) is critical here. When building the react package, shared/ReactSharedInternals is forked to react/src/ReactSharedInternalsClient.js — the direct definition. When building other packages like react-dom, it resolves to shared/ReactSharedInternals.js which reads React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.

The Hooks Linked List — fiber.memoizedState

Inside the reconciler, each hook call during render creates or traverses a Hook node in a singly-linked list anchored at fiber.memoizedState. The mountWorkInProgressHook function creates new nodes:

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,  // The hook's own state
    baseState: null,      // Base state for update rebasing
    baseQueue: null,      // Updates that weren't processed
    queue: null,          // Circular linked list of pending updates
    next: null,           // Link to next hook
  };

  if (workInProgressHook === null) {
    // First hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

This is why hooks must be called in the same order every render. On update, React traverses the existing linked list in order, matching each hook call to its corresponding node. If you conditionally skip a hook, every subsequent hook reads the wrong node's state.

graph LR
    FM["fiber.memoizedState"] --> H1["Hook 1<br/>(useState)"]
    H1 -->|next| H2["Hook 2<br/>(useEffect)"]
    H2 -->|next| H3["Hook 3<br/>(useMemo)"]
    H3 -->|next| N["null"]

    style H1 fill:#e1f5fe
    style H2 fill:#f3e5f5
    style H3 fill:#e8f5e9

What each hook stores in memoizedState varies by hook type:

Hook memoizedState contents
useState The current state value
useReducer The current state value
useRef {current: value}
useMemo [computedValue, deps]
useCallback [callback, deps]
useEffect An Effect object
useContext No Hook node (reads from context stack directly)

Mount vs Update Dispatchers

The reconciler maintains three distinct dispatcher objects, defined at line 3898:

HooksDispatcherOnMount — Creates new Hook nodes and initializes state:

const HooksDispatcherOnMount: Dispatcher = {
  useState: mountState,
  useEffect: mountEffect,
  useRef: mountRef,
  useMemo: mountMemo,
  // ...
};

HooksDispatcherOnUpdate — Traverses existing Hook nodes and processes queued updates:

const HooksDispatcherOnUpdate: Dispatcher = {
  useState: updateState,
  useEffect: updateEffect,
  useRef: updateRef,
  useMemo: updateMemo,
  // ...
};

HooksDispatcherOnRerender — Used when a state update is triggered during render (setState called in the render function body):

const HooksDispatcherOnRerender: Dispatcher = {
  useState: rerenderState,
  useReducer: rerenderReducer,
  // most hooks delegate to the update version
};

The swap happens in renderWithHooks, which is called by beginWork when processing a function component:

export function renderWithHooks(current, workInProgress, Component, props, ...) {
  currentlyRenderingFiber = workInProgress;
  workInProgress.memoizedState = null; // Reset for mount
  workInProgress.updateQueue = null;

  ReactSharedInternals.H =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // Now call the component function
  let children = Component(props, secondArg);
  // ...
}

If current is null (first render), the mount dispatcher is installed. Otherwise, the update dispatcher is installed. After the component function returns, renderWithHooks resets ReactSharedInternals.H to null, which is why calling hooks outside render throws.

flowchart TD
    RWH["renderWithHooks(current, wip, Component)"]
    Check{current === null?}
    Mount["H = HooksDispatcherOnMount"]
    Update["H = HooksDispatcherOnUpdate"]
    Call["children = Component(props)"]
    Reset["H = null"]

    RWH --> Check
    Check -->|Yes| Mount
    Check -->|No| Update
    Mount --> Call
    Update --> Call
    Call --> Reset

useState and useReducer — The Update Queue

useState is actually implemented on top of useReducer with a built-in reducer. The mountState function creates a Hook node with an update queue:

function mountState(initialState) {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

When you call the dispatch function (your setState), dispatchSetState creates an update object and adds it to a circular linked list on hook.queue. The update is also assigned a lane (priority level) and the fiber is scheduled for re-render.

An important optimization: dispatchSetState performs eager state computation. If no render is in progress and the new state is the same as the current state (checked via Object.is), the update can be skipped entirely — no re-render is scheduled. This is why setState(prevState) with the same value is cheap.

During the update render, updateReducer processes all queued updates by replaying them in order. For concurrent rendering, some updates may need to be rebased — if a higher-priority render interrupts a lower-priority one, the lower-priority updates are kept in baseQueue and replayed on top of the baseState.

The Effect System — Passive, Layout, and Insertion Effects

Effects are stored differently from state hooks. When you call useEffect, the hook's memoizedState points to an Effect object, and the effect is also pushed onto fiber.updateQueue (which for function components is a circular linked list of effects).

The ReactHookEffectTags.js file defines the tag bits:

export const NoFlags   = 0b0000;
export const HasEffect = 0b0001;  // Effect needs to fire
export const Insertion = 0b0010;  // useInsertionEffect
export const Layout    = 0b0100;  // useLayoutEffect
export const Passive   = 0b1000;  // useEffect

HasEffect is the key flag — it's set when the effect's dependencies have changed. Without it, the effect object still exists on the fiber (needed for cleanup tracking), but the commit phase knows not to re-run it.

The three effect types fire at different points in the commit phase (as we covered in Article 3):

flowchart LR
    subgraph "Commit Phase Timing"
        IE["useInsertionEffect<br/>tag: Insertion | HasEffect<br/>Fires in: Mutation phase<br/>Before DOM updates visible"]
        LE["useLayoutEffect<br/>tag: Layout | HasEffect<br/>Fires in: Layout phase<br/>After DOM mutation, before paint"]
        PE["useEffect<br/>tag: Passive | HasEffect<br/>Fires in: Passive phase<br/>Asynchronous, after paint"]
    end

    IE --> LE --> PE

The mountEffect function creates an effect with the Passive tag and sets the Passive flag on the fiber (which feeds into subtreeFlags bubbling, enabling the commit phase to skip subtrees without passive effects).

Other Hooks — useRef, useMemo, useCallback, useContext

The remaining hooks are simpler in implementation:

useRefmountRef creates a {current: initialValue} object and stores it in hook.memoizedState. On update, updateRef simply returns the existing object — refs are never recreated.

function mountRef(initialValue) {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

useMemo and useCallback store [value, deps] tuples. On update, React compares the new deps with the stored deps using Object.is on each element. If all deps match, the memoized value is returned; otherwise, the function is re-executed (for useMemo) or the new callback is stored (for useCallback).

useContext is unique — it doesn't create a Hook node at all. It reads directly from the reconciler's context stack via readContext. This is why useContext doesn't need to follow hook ordering rules in theory, though React still tracks it in DEV mode for consistency.

useTransition is interesting because it integrates with the lane system. When you call startTransition(callback), React temporarily sets the update priority to a TransitionLane before calling your callback. Any setState calls inside the callback are assigned transition lanes and rendered with time-slicing.

Tip: The "rules of hooks" aren't enforced by some magical linter — they're a structural requirement of the linked list. Each hook call must correspond to the same position in the list on every render. If you add a conditional hook, Hook 3 might read Hook 2's state, causing subtle and hard-to-debug bugs.

What's Next

We've now seen how hooks bridge the react and react-reconciler packages, how state is stored on fibers, and how the effect system integrates with the commit phase. In the next article, we'll cross the boundary from the reconciler into the renderer, examining the host config contract that makes React renderer-agnostic, and tracing how react-dom-bindings implements it for the browser DOM — including the event delegation system and DOM mutation operations.