6.1 How hooks store state on a Fiber
A function component runs top to bottom and then it is gone — its local variables disappear the moment it returns. So where does the value behind useState(0) actually live between renders, and why does React insist you call your hooks in the same order every single time? This lesson follows that state to its real home: a small linked list hanging off the component's Fiber.
A function forgets — the Fiber remembers
Run a function component and its body executes once, returns some elements, and exits. Nothing inside it survives. That is a problem: useState has to remember its value for the next render.
The answer is that the value is not stored in the function at all. It is stored on the Fiber, in a field called memoizedState.
Each call to a hook during a render creates (or reuses) one small object — a hook node. All the nodes from one component join into a single singly-linked list, and the head of that list lives on fiber.memoizedState:
Fiber.memoizedState → Hook1 → Hook2 → Hook3 → null
↓ ↓ ↓
state effect ref
So a component that calls useState, then useEffect, then useRef ends up with a three-node chain. The first hook is the head; each node points to the next with a next field; the last points to null.
One node per hook call
Every hook node has the same shape. It carries the hook's current value plus the bookkeeping React needs to process updates, and the all-important next pointer that links it to the following hook.
The real code
// Simplified hook node structure
const hook = {
memoizedState: any, // The hook's current value (state, ref, callback…)
baseState: any, // Base state before any skipped low-priority updates
baseQueue: Update | null,// Queue of updates that were deferred
queue: UpdateQueue, // Pending updates (circular linked list)
next: Hook | null // Pointer to next hook in the list
};
Notice the name collision: the Fiber has a memoizedState field (the head of the hook list), and each hook node also has a memoizedState field (that one hook's own value). Same name, two different levels.
React keeps the list building with just two module-level cursors. One walks the previous render's list; the other builds the current one.
The real code — the cursors that drive everything
var renderLanes = 0,
currentlyRenderingFiber = null, // The fiber currently being rendered
currentHook = null, // Cursor into the PREVIOUS render's hook list
workInProgressHook = null, // Cursor into the CURRENT render's hook list
didScheduleRenderPhaseUpdate = !1,
localIdCounter = 0,
thenableIndexCounter = 0, // Separate counter for use() — NOT in the hook list
thenableState = null, // Separate state for use() thenables
currentHookNameInDev = null, // DEV: which hook is currently executing
hookTypesDev = null, // DEV: array of hook names from previous render
hookTypesUpdateIndexDev = -1; // DEV: cursor into hookTypesDev for comparison
The two that matter right now are currentHook (reading the old list) and workInProgressHook (building the new one). Everything else about hook storage is built on these two.
First render: build the list (mount)
On a component's first render there is no previous list to copy from, so each hook just allocates a fresh node and links it on the end. That is the whole job of mountWorkInProgressHook.
The real code — mountWorkInProgressHook()
function mountWorkInProgressHook() {
var hook = {
memoizedState: null, // The stored state value
baseState: null, // Base state for update processing
baseQueue: null, // Linked list of pending updates
queue: null, // The update queue
next: null // ← THE LINKED LIST POINTER
};
null === workInProgressHook
? (currentlyRenderingFiber.memoizedState = workInProgressHook = hook)
: (workInProgressHook = workInProgressHook.next = hook);
return workInProgressHook;
}
Read the two branches in plain English. If workInProgressHook is still null, this is the first hook of the component, so its node becomes the head: it is stored straight onto fiber.memoizedState. Otherwise it is a later hook, so it is appended after the last one via .next. Either way the cursor moves forward to the node just added.
Call three hooks and you have built exactly the chain from before:
fiber.memoizedState → Hook1 → Hook2 → Hook3 → null
(useState) (useEffect) (useState)
Every later render: walk the list (update)
On every render after the first, the list already exists — it is sitting on the previous Fiber. Now the job is not to allocate fresh nodes but to walk the existing list one step at a time, copying each old node forward so this render gets its value back. That is updateWorkInProgressHook.
The real code — updateWorkInProgressHook()
function updateWorkInProgressHook() {
if (null === currentHook) {
var nextCurrentHook = currentlyRenderingFiber.alternate;
nextCurrentHook =
null !== nextCurrentHook ? nextCurrentHook.memoizedState : null;
} else nextCurrentHook = currentHook.next; // advance one step
var nextWorkInProgressHook =
null === workInProgressHook
? currentlyRenderingFiber.memoizedState
: workInProgressHook.next;
if (null !== nextWorkInProgressHook)
(workInProgressHook = nextWorkInProgressHook),
(currentHook = nextCurrentHook);
else {
// CRITICAL: no previous hook at this position — crash
if (null === nextCurrentHook) {
throw Error("Rendered more hooks than during the previous render.");
}
// Clone previous hook's data into new linked list node
currentHook = nextCurrentHook;
nextCurrentHook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null
};
null === workInProgressHook
? (currentlyRenderingFiber.memoizedState = workInProgressHook =
nextCurrentHook)
: (workInProgressHook = workInProgressHook.next = nextCurrentHook);
}
return workInProgressHook;
}
The key line is the very first else: nextCurrentHook = currentHook.next. Each call advances exactly one step down the old list. The first hook call this render reads slot 1; the second reads slot 2; the third reads slot 3. There is no name, no key, no ID anywhere — a hook is matched to its previous value purely by call order.
And look at what happens if this render calls one hook too many: nextCurrentHook falls off the end and becomes null, so React throws "Rendered more hooks than during the previous render." The list itself is the safety check.
The dispatcher: one name, two implementations
Here is the piece that makes mount and update feel seamless. The useState you import is not a fixed function. It is a thin forwarder that calls through a swappable object React keeps at ReactSharedInternals.H — the dispatcher.
Before React runs your component, it points the dispatcher at the right set of internal functions: the mount dispatcher on the first render (so useState routes to the mount path) and the update dispatcher on every render after (so the same useState routes to the walk-the-list path). Same public API, completely different machinery underneath.
You can see the swap happen at the very top of renderWithHooks — the function that wraps every component render:
The real code — renderWithHooks()
function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
renderLanes = nextRenderLanes;
currentlyRenderingFiber = workInProgress;
workInProgress.memoizedState = null; // ① Reset hook list to empty
workInProgress.updateQueue = null;
// ② Choose the right dispatcher
ReactSharedInternals.H =
null !== current && null !== current.memoizedState
? HooksDispatcherOnUpdateInDEV // Re-render
: HooksDispatcherOnMountInDEV; // Fresh mount
var children = callComponentInDEV(Component, props, secondArg); // ③ Call component
finishRenderingHooks(current, workInProgress); // ④ Cleanup
return children;
}
Read the test in step ②: if there is a previous Fiber (current) and it already has a hook list (current.memoizedState), this is a re-render, so use the update dispatcher. Otherwise it is a fresh mount. That single check is what decides whether your useState call allocates a node or walks the list.
See it in the source: the dispatcher as a guardrail
The dispatcher is not only a convenience — it is also how React makes hooks illegal at the wrong time. When no component is rendering, React points the dispatcher at ContextOnlyDispatcher, where almost every hook is wired straight to a throw.
The real code — ContextOnlyDispatcher
ContextOnlyDispatcher = {
readContext: readContext,
use: use, // use() STILL WORKS
useCallback: throwInvalidHookError, // Everything else THROWS
useContext: throwInvalidHookError,
useEffect: throwInvalidHookError,
useImperativeHandle: throwInvalidHookError,
useLayoutEffect: throwInvalidHookError,
useMemo: throwInvalidHookError,
useReducer: throwInvalidHookError,
useRef: throwInvalidHookError,
useState: throwInvalidHookError,
// ... all hooks map to throwInvalidHookError
};
function throwInvalidHookError() {
throw Error(
"Invalid hook call. Hooks can only be called inside of the body " +
"of a function component..."
);
}
This is the real reason for the rule "only call hooks from inside a function component." Outside a render, the dispatcher simply does not have working hook functions installed — call useState in a click handler or a plain helper and you hit throwInvalidHookError. The famous error message is literally a property on this object.
Show the full source — how the swap is set up and torn down
After your component returns, finishRenderingHooks flips the dispatcher back to the "no hooks allowed" trap and also catches the opposite mistake — calling fewer hooks than last time (an early return that skipped a hook):
The real code — finishRenderingHooks()
function finishRenderingHooks(current, workInProgress) {
// ① Switch to ContextOnlyDispatcher — no more hooks allowed
ReactSharedInternals.H = ContextOnlyDispatcher;
// ② Check for unconsumed hooks in previous render's list
var didRenderTooFewHooks =
null !== currentHook && null !== currentHook.next;
// ③ Reset all state
renderLanes = 0;
currentHook = workInProgressHook = currentlyRenderingFiber = null;
thenableIndexCounter = 0;
thenableState = null;
// ④ If leftover hooks remain → crash
if (didRenderTooFewHooks)
throw Error(
"Rendered fewer hooks than expected. This may be caused by " +
"an accidental early return statement."
);
}
So the two failure directions are caught in two different places: too many hooks crashes inside updateWorkInProgressHook (it runs off the end of the old list), while too few hooks is caught here, by noticing the previous list still had unconsumed nodes (currentHook.next is not null).
The full lifecycle of the dispatcher across one render looks like this:
Component render starts
│
▼
ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV ← Hooks ALLOWED ✅
│
▼
Component function executes
│
├── useState() → works normally ✅
├── useEffect() → works normally ✅
├── useMemo(() => {
│ ReactSharedInternals.H = InvalidNestedHooksDispatcher ← Hooks WARN ⚠️
│ useState() → warnInvalidHookAccess() + still runs
│ })
│ ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV ← Restored ✅
│
▼
finishRenderingHooks()
│
▼
ReactSharedInternals.H = ContextOnlyDispatcher ← Hooks THROW ❌
│
▼
Any hook call → "Invalid hook call. Hooks can only be called
inside of the body of a function component..."
Why the rules of hooks exist
Now the three "rules of hooks" stop being arbitrary etiquette and become direct consequences of the linked list.
Because updateWorkInProgressHook advances by exactly one slot per call, the nth hook call always reads the nth node. Put a hook behind an if, and on some renders that call disappears — every hook after it shifts up by one slot and starts reading the wrong node's value.
Here is what a conditional hook does to the chain:
Previous render: Hook1(useState) → Hook2(useEffect) → null
↑ ↑
call #1 call #2
Current render (condition changed):
Hook1(useState) → Hook2(useState) → ???
↑ ↑ ↑
call #1 call #2 call #3 → CRASH
(was useEffect, "Rendered more hooks
now gets useState than previous render"
data → CORRUPTION)
That is why the rule is "call hooks at the top level only" — never inside a condition, loop, or nested function. The fix is always the same: keep the hook calls unconditional and put the condition inside the hook, not around it.