Chapter 27

Hooks Linked List: How useState and useEffect Are Implemented

Every React developer knows that Hooks cannot be called inside conditionals or loops. But a rule alone does not satisfy technical curiosity — what we really want to know is: why not? What implementation mechanism is hiding behind this rule? Diving into the source of ReactFiberHooks.js reveals that Hooks are a carefully designed linked list data structure, and this rule is the inevitable requirement of maintaining consistent list order.

The Hook Object: One Node in the Linked List

Each Hook call (useState, useEffect, useRef, etc.) corresponds to one node in a linked list attached to the Fiber node:

// packages/react-reconciler/src/ReactFiberHooks.js
// The Hook object structure
export type Hook = {
  memoizedState: any,    // Current stable state (for useState: current value; for useEffect: the effect object)
  baseState: any,        // Base state (used to compute the final value from the update queue)
  baseQueue: Update<any, any> | null,  // Base update queue (low-priority updates not yet processed)
  queue: UpdateQueue<any, any> | null, // Current update queue (circular linked list)
  next: Hook | null,     // Points to the next Hook node
};

These Hook nodes form a linked list hanging off the Fiber node's memoizedState field. Consider this component:

function Counter() {
  const [count, setCount] = useState(0);   // Hook 1
  const [name, setName] = useState('');    // Hook 2
  useEffect(() => {                        // Hook 3
    document.title = `Count: ${count}`;
  }, [count]);
  return <div>{count}</div>;
}

During rendering, the memoizedState linked list on the Fiber node is:

Hook1 (count) → Hook2 (name) → Hook3 (effect) → null

React uses two global pointers, currentHook and workInProgressHook, to track which position in the linked list is currently being processed. Each Hook call advances the pointer by one step. If a Hook is skipped in some render (e.g., a conditional prevents a Hook from executing), the nodes in the list become misaligned — React treats the second node as the first, and state becomes completely scrambled. This is the fundamental reason for the "don't call Hooks inside conditionals" rule.

Dual Dispatchers: Separating Mount and Update

React uses entirely different function implementations to handle the initial render (mount) versus subsequent updates:

// packages/react-reconciler/src/ReactFiberHooks.js
const HooksDispatcherOnMount = {
  useState: mountState,
  useEffect: mountEffect,
  useRef: mountRef,
  useMemo: mountMemo,
  useCallback: mountCallback,
  // ...
};

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

// In renderWithHooks, switch the dispatcher based on whether this is the first render
function renderWithHooks(current, workInProgress, Component, props, ...) {
  // current === null means this is the initial mount
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
  
  // Call the component function — all useState etc. calls route through the current dispatcher
  let children = Component(props, secondArg);
  
  // After rendering completes, switch to the error dispatcher to prevent
  // Hook calls from outside the render phase
  ReactCurrentDispatcher.current = ContextOnlyDispatcher;
  
  return children;
}

This design is elegant: by swapping the object that ReactCurrentDispatcher.current points to, the same useState call executes completely different code paths in different scenarios. This is the Strategy Pattern in action.

mountState: The Initial useState Implementation

// packages/react-reconciler/src/ReactFiberHooks.js
function mountState(initialState) {
  // Create a new Hook node and append it to the end of the linked list
  const hook = mountWorkInProgressHook();
  
  // Support lazy initialization: initialState can be a function
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  
  hook.memoizedState = hook.baseState = initialState;
  
  // Create the update queue (circular linked list)
  const queue = {
    pending: null,        // Pending updates (head of the circular linked list)
    lanes: NoLanes,
    dispatch: null,       // The dispatch function
    lastRenderedReducer: basicStateReducer,  // useState uses basicStateReducer
    lastRenderedState: initialState,
  };
  
  hook.queue = queue;
  
  // Create and bind the dispatch function
  // dispatch is bound to the current Fiber and queue, ensuring every setCount call
  // updates the correct state
  const dispatch = (queue.dispatch = dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ));
  
  return [hook.memoizedState, dispatch];
}

// The reducer used by useState (simply returns the new value or calls the updater function)
function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

updateState: The Update useState Implementation

During updates, React must handle potentially multiple queued setState calls:

// packages/react-reconciler/src/ReactFiberHooks.js
function updateState(initialState) {
  // updateReducer is the general implementation;
  // useState is just useReducer with basicStateReducer
  return updateReducer(basicStateReducer, initialState);
}

function updateReducer(reducer, initialArg, init) {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  
  queue.lastRenderedReducer = reducer;
  
  const current = currentHook;
  
  // Process baseQueue (updates skipped in the previous render due to insufficient priority)
  let baseQueue = current.baseQueue;
  
  // Merge pendingQueue and baseQueue
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    if (baseQueue !== null) {
      // Append pendingQueue to the end of baseQueue
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }
  
  if (baseQueue !== null) {
    const first = baseQueue.next;
    let newState = current.baseState;
    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast = null;
    let update = first;
    
    do {
      const updateLane = removeLanes(update.lane, OffscreenLane);
      const isHiddenUpdate = updateLane !== update.lane;
      
      // Check whether this update's priority falls within the current render's lanes
      const shouldSkipUpdate = isHiddenUpdate
        ? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
        : !isSubsetOfLanes(renderLanes, updateLane);
      
      if (shouldSkipUpdate) {
        // Insufficient priority — skip this update, keep it in baseQueue
        const clone = { ...update };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
      } else {
        // Sufficient priority — apply this update
        if (newBaseQueueLast !== null) {
          // If earlier updates were skipped, this one must also go into baseQueue
          // to preserve ordering
          newBaseQueueLast = newBaseQueueLast.next = { ...update, lane: NoLane };
        }
        
        // Compute new state
        newState = reducer(newState, update.action);
      }
      
      update = update.next;
    } while (update !== null && update !== first);
    
    hook.memoizedState = newState;
    hook.baseState = newBaseState === null ? newState : newBaseState;
    hook.baseQueue = newBaseQueueLast;
    
    queue.lastRenderedState = newState;
  }
  
  const dispatch = queue.dispatch;
  return [hook.memoizedState, dispatch];
}

dispatchSetState: How Updates Are Triggered

When you call setCount(1), what actually executes is dispatchSetState:

// packages/react-reconciler/src/ReactFiberHooks.js (simplified)
function dispatchSetState(fiber, queue, action) {
  const lane = requestUpdateLane(fiber);
  
  const update = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: null,
  };
  
  // Optimization: if no other render is in progress, try computing the new state eagerly.
  // If the new state equals the current state, skip this render entirely (bail out).
  if (isRenderPhaseUpdate(fiber)) {
    // Update during the render phase (calling setState directly inside the component function)
    didScheduleRenderPhaseUpdateDuringPass = true;
  } else {
    if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
      // No other pending work on this Fiber — try the eager state optimization
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        try {
          const currentState = queue.lastRenderedState;
          const eagerState = lastRenderedReducer(currentState, action);
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            // New and old state are the same — skip re-render
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return;
          }
        } catch (error) {
          // Ignore the error; it will be recomputed during the normal render
        }
      }
    }
    
    // Enqueue the update in the queue's circular linked list
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
    }
  }
}

useEffect: The Effect Object

The internal implementation of useEffect has a similar structure to useState, but memoizedState stores an Effect object rather than a state value:

// The Effect object structure for useEffect
type Effect = {
  tag: HookFlags,          // Identifies the effect type (Passive=useEffect, Layout=useLayoutEffect, etc.)
  create: () => (() => void) | void,  // The setup function
  inst: {
    destroy: (() => void) | void,     // The cleanup function (return value of setup)
    tag: HookFlags,
  },
  deps: Array<mixed> | null,  // The dependency array
  next: Effect,               // Points to the next Effect (forming a circular linked list)
};

// packages/react-reconciler/src/ReactFiberHooks.js
function mountEffect(create, deps) {
  return mountEffectImpl(
    PassiveEffect | PassiveStaticEffect,  // Fiber flags
    HookPassive,                           // Hook tag
    create,
    deps,
  );
}

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  
  // Mark the Fiber node as needing passive effect processing
  currentlyRenderingFiber.flags |= fiberFlags;
  
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,  // Initial mount: always execute the effect
    create,
    createEffectInstance(),
    nextDeps,
  );
}

function updateEffect(create, deps) {
  return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}

function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const effect = hook.memoizedState;
  const inst = effect.inst;
  
  if (currentHook !== null) {
    if (nextDeps !== null) {
      const prevDeps = currentHook.memoizedState.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // Dependencies unchanged — do NOT mark HookHasEffect (will not re-run)
        hook.memoizedState = pushEffect(hookFlags, create, inst, nextDeps);
        return;
      }
    }
  }
  
  // Dependencies changed or no dependency array — mark as needing execution
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    inst,
    nextDeps,
  );
}

HookHasEffect is the critical flag — only Effects carrying this flag will actually have their create function invoked during the commit phase. When dependencies are unchanged, the Effect object still exists in the linked list (preserving list order) but without the HookHasEffect flag, so it does not execute.

Dependency Comparison: areHookInputsEqual

// packages/react-reconciler/src/ReactFiberHooks.js
function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) return false;
  
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

is is a wrapper around Object.is, using strict equality with special handling for -0 and NaN. This means objects and arrays are always considered "different" even when their contents are identical, because the comparison is by reference — which is exactly why placing an object literal in a useEffect dependency array causes the effect to re-run on every render.

Once you understand how the Hooks linked list works, your understanding of React elevates from "knowing the rules" to "understanding why the rules are inevitable." Hooks are not magic. They are a careful application of linked list data structures, and the stable order of that linked list is the fundamental guarantee that enables function components to have persistent state.

Rate this chapter
4.8  / 5  (3 ratings)

💬 Comments