React Hooks Dispatcher: How useState Resolves
How to read this page
Symvanta parsed this repository into a code graph: every function, class, and method is a node, and every call or import between them is an edge. Everything on this page is computed from that graph at the commit shown above. This page is a deep dive into one subsystem of React Architecture: How It Actually Works; the hub page holds the whole-repo module map and glossary.
When you call useState in a component, the function in the react package does almost nothing: it reads a mutable slot called the dispatcher and forwards the call to whatever the reconciler installed there a moment ago. That indirection is the entire hooks mechanism. The react package ships a hooks API with no implementation, and the reconciler swaps in one of two concrete implementations right before it runs your component. Symvanta's graph shows the two halves living in different clusters: the public hook functions sit in the cluster the module map calls react-api-and-fizz (717 symbols), while the mount and update implementations, plus the machinery that installs them, live in the react-reconciler cluster (1,980 symbols). The single field that connects them is ReactSharedInternals.H.
This indirection is why the same useState line behaves differently on a component's first render than on every render after, why hook state can live on the fiber instead of in a closure, and why the "Invalid hook call" warning exists at all. This analysis is generated from Symvanta's code graph over facebook/react at commit eafeac0. It covers how React implements hooks; if you are looking for frontend architecture for React applications you build, the companion post covers that.
The moving parts
ReactSharedInternalsresolveDispatcherDispatcherrenderWithHooksHooksDispatcherOnMountHooksDispatcherOnUpdatemountStatemountWorkInProgressHookupdateWorkInProgressHook
ReactSharedInternals is a module-level singleton, re-exported from React's internal client bundle, whose H field holds the currently active dispatcher (or null). resolveDispatcher is the function every public hook calls first; it returns ReactSharedInternals.H, and in production that is all two statements of it (the definition spans lines 24 to 42 because of the development-only null check). The Dispatcher interface is the contract both implementations satisfy: useState, useReducer, useEffect, useContext, useRef, and the rest, roughly two dozen slots. HooksDispatcherOnMount and HooksDispatcherOnUpdate are the two concrete objects: on mount, useState maps to mountState; on update, it maps to updateState (which delegates to updateReducer). mountWorkInProgressHook and updateWorkInProgressHook are the two functions that manage where hook state lives: a singly linked list of Hook objects hanging off the fiber's memoizedState field, one node per hook call, in call order.
How it works
Here is the canonical path a useState call travels, showing where the first render and every render after diverge.
useStatein thereactpackage spans six lines, two of them the body:const dispatcher = resolveDispatcher(); return dispatcher.useState(initialState);. It holds no state and knows nothing about fibers. Every other hook (useEffect,useReducer,useContext) has the same shape.resolveDispatcherreturnsReactSharedInternals.H. In development it first checks whetherHisnulland, if so, logs the "Invalid hook call. Hooks can only be called inside of the body of a function component" message. The comment in the source notes it deliberately does not throw its own error (a null access will throw naturally, and skipping the check keeps this hot path inlinable).renderWithHooksis the reconciler function that madeHnon-null in the first place. Before it calls your component, it resets the work-in-progress fiber'smemoizedState,updateQueue, andlanes, then assignsReactSharedInternals.Hto either the mount or the update dispatcher. The choice is one condition:current !== null && current.memoizedState !== null, meaning the fiber already rendered once and used at least one stateful hook, selects the update dispatcher; otherwise the mount dispatcher.HooksDispatcherOnMountis installed on first render. ItsuseStateslot ismountState, which callsmountWorkInProgressHookto allocate a freshHook, stores the initial state, and bindsdispatchSetStateto the fiber and the hook's queue to produce the setter you get back.mountWorkInProgressHookis where hook state attaches to the fiber. The first hook call setscurrentlyRenderingFiber.memoizedStateto the new node; each later call appends to the previous node'snext. The result is a linked list whose order is exactly the order your hooks were called, with no keys or names.HooksDispatcherOnUpdateis installed on every render after the first. ItsuseStateslot isupdateState, which forwards toupdateReducerwith a basic state reducer, since a state update is just a reducer update in disguise.updateWorkInProgressHookwalks that list by position on re-render. It reads the next node from the alternate fiber'smemoizedState(the previous render's list) and clones it forward. If it runs off the end of the list, it throws "Rendered more hooks than during the previous render." That single positional walk is why the Rules of Hooks forbid calling hooks conditionally: the list has no names, so position is the only identity a hook has.
Where it connects
The dispatcher is a deliberately thin seam between two clusters that the module map keeps separate. The public hook functions and the element helpers around them (memo, forwardRef, createElement, getComponentNameFromType) cluster together in react-api-and-fizz, while the dispatchers, the Hook list machinery, and renderWithHooks live in react-reconciler. Neither imports the other's internals; they meet only through ReactSharedInternals.H, which is also how a react-dom build and a react-native build can install different renderers behind the identical react package the app imports. The graph puts 430 calls on the reconciler's edge into that cluster, the heaviest on the whole map, and 133 coming back.
That seam is the same one the Fiber reconciler spoke describes from the render side: renderWithHooks is called from beginWork as the reconciler descends the tree, and the memoizedState linked list every hook reads and writes hangs off the very Fiber objects the render phase walks and the commit phase applies. When dispatchSetState (the setter mountState bound in step 4) fires later, it schedules a new render, renderWithHooks runs again, and this time the update dispatcher walks the existing list instead of building it.
By the numbers
The Dispatcher interface is satisfied by four objects in ReactFiberHooks.js at this commit: ContextOnlyDispatcher (line 3898), HooksDispatcherOnMount (3926), HooksDispatcherOnUpdate (3954), and HooksDispatcherOnRerender (3982), each mapping roughly two dozen hook names to distinct functions, plus a parallel set of development-mode dispatchers that add hook-order validation. ContextOnlyDispatcher is the one installed outside render: every hook slot on it is throwInvalidHookError, which is what actually raises the "Invalid hook call" error a hook called from an event handler or a module body hits. renderWithHooks spans 130 lines (505 to 634), most of it the branching that selects which of those dispatchers to install. After the first render ever performed, ReactSharedInternals.H stays non-null for the life of the page; what changes is which object sits there, and "inside the body of a function component" is the window where that object is a real mount or update dispatcher.
Auto-generated by Symvanta from the public repo facebook/react at commit eafeac0 , licensed MIT .