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.