Hooks
A component's state is a linked list on its fiber, walked by position; setState is an append to a queue processed by lane; an effect is an object with a tag that says which commit pass runs it. This part is the list and why the rules exist, the update queue with lanes and the base-queue rebase, effect objects and the timing table, refs and memoisation and the specialised hooks, and the tools.
The hook list
"Why can a hook not be inside an if? What actually breaks?"
The list breaks. A function component's state is a linked list of Hook objects on its fiber, one per hook call, in call order, with no names. On every render React walks the list from the head, handing node 1 to the first call, node 2 to the second. A call that is sometimes skipped shifts every call after it onto the wrong node: a useRef reads an effect's node, a useMemo reads a ref's node, and React throws when the shapes do not match. The rule of hooks is a statement about a linked list.
// the shapes, abridged from ReactFiberHooks.js
type Hook = { memoizedState: any, baseState: any, baseQueue: Update | null, queue: UpdateQueue | null, next: Hook | null }
type Effect = { tag: HookFlags, create: () => (() => void) | void, destroy: (() => void) | void, deps: any[] | null, next: Effect }
function mountState(initial) {
const hook = mountWorkInProgressHook()
hook.memoizedState = hook.baseState = typeof initial === 'function' ? initial() : initial // the initialiser runs once (twice in Strict dev)
const queue = { pending: null, lanes: NoLanes, dispatch: null, lastRenderedReducer: basicStateReducer, lastRenderedState: hook.memoizedState }
hook.queue = queue
return [hook.memoizedState, (queue.dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue))] // dispatch is stable: bound once
}
function updateState(initial) { return updateReducer(basicStateReducer, initial) } // the queue walk from the figure above
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook()
currentlyRenderingFiber.flags |= fiberFlags // Passive | PassiveStatic, or Update | LayoutStatic
hook.memoizedState = pushEffect(HasEffect | hookFlags, create, undefined, deps) // linked into fiber.updateQueue.lastEffect (circular)
}
function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = updateWorkInProgressHook()
const prevEffect = hook.memoizedState
if (deps !== null && areHookInputsEqual(deps, prevEffect.deps)) { // Object.is per element
hook.memoizedState = pushEffect(hookFlags, create, prevEffect.destroy, deps) // no HasEffect: commit skips it
return
}
currentlyRenderingFiber.flags |= fiberFlags
hook.memoizedState = pushEffect(HasEffect | hookFlags, create, prevEffect.destroy, deps) // HasEffect: commit runs destroy then create
}
function mountMemo(nextCreate, deps) { const hook = mountWorkInProgressHook(); const v = nextCreate(); hook.memoizedState = [v, deps]; return v }
function updateMemo(nextCreate, deps) { const hook = updateWorkInProgressHook(); const [prev, prevDeps] = hook.memoizedState
if (deps !== null && areHookInputsEqual(deps, prevDeps)) return prev; const v = nextCreate(); hook.memoizedState = [v, deps]; return v }
function mountRef(initial) { const hook = mountWorkInProgressHook(); const ref = { current: initial }; hook.memoizedState = ref; return ref }
function updateRef() { return updateWorkInProgressHook().memoizedState } // the same object, every render; mutating .current renders nothingrenderWithHooks(current, wip, Component, props): setcurrentlyRenderingFiber; choose the dispatcher (mount ifcurrent === nullor no hooks yet, else update); callComponent(props); after it returns, check that the update walk consumed exactly the current list (else throw); reset the dispatcher to one that throws ("hooks can only be called inside a component").- Mount dispatcher: each hook allocates a node and appends; initialisers run; dispatch functions are created and bound to the fiber and queue (so
setStateis referentially stable forever). - Update dispatcher: each hook clones the corresponding current node into the WIP list and runs its update logic: process a queue, compare deps, return the stored value.
- Re-render dispatcher: a
setStateduring render (allowed for the same component) marks the fiber to render again immediately with the queued updates applied, up to a limit (25) before "too many re-renders".
- Order is identity; hence no conditions, loops, or early returns before hooks. The ESLint rule enforces it; the Compiler relies on it.
- Custom hooks are inlined positions:
useForm()calling five hooks adds five nodes to the caller's list. Two components using the same custom hook have separate lists: no shared state unless the hook reaches outside (a store, context). setStateanddispatchare stable (bound once at mount), which is why they can be omitted from dependency arrays.- Initialisers run on mount only (
useState(() => expensive())); on update the argument is ignored entirely.
useState and useReducer: the queue
setState does not set anything; it appends an update to a queue on the hook and schedules a render. The render processes the queue for the lanes it is rendering, in dispatch order, and keeps a base of skipped updates for other lanes. That is why state is "asynchronous", why updater functions see the latest value, why updates batch, and why a transition and a sync update to the same hook never corrupt each other.
- Lane:
requestUpdateLane(fiber)(part 3). - Eager path: if the fiber has no pending lanes (the queue is empty), compute
eagerState = lastRenderedReducer(lastRenderedState, action)now; ifObject.isto the current state, return without scheduling anything. Otherwise store the eager result on the update so the render can skip recomputing it. - Enqueue: append the update to
queue.pending(circular: pending points to the last; last.next to the first). Concurrent-safe because render never mutates the pending list; it moves it to the base queue atomically at the start of processing. - Schedule: mark lanes up the tree;
ensureRootIsScheduled.
- Merge: append the pending list to the hook's base queue (from a previous render's skipped updates).
- Walk from the first update: if its lane is in
renderLanes, apply (reducer(state, action), or the eager state if present); else skip: if this is the first skip, recordbaseStateas the state so far and start a new base queue with this update; and mark the fiber's lanes with the skipped lane so it is rendered later. After a skip, every subsequent update is also cloned into the new base queue (even if applied now), so the later render replays the full sequence from the base. - Finish:
memoizedState= the computed state (what this render shows);baseStateandbaseQueueas recorded; if the new state isObject.isto the old,markWorkInProgressReceivedUpdateis not called and the component may late-bail (part 2).
- "State updates are asynchronous": the queue is processed in the next render; the handler sees the old value.
- Updater functions see the latest state because they are applied in order during the walk, each to the previous result.
setX(sameValue)is nearly free: the eager path bails before scheduling.- Batching is the queue holding several updates when the render runs.
- Lanes do not corrupt each other because of the base queue rebase; the cost is that a sync render may do slightly more work (cloning updates) when a transition is pending on the same hook.
useReduceris the same with your reducer; the eager path uses it too. A reducer that is not pure (closes over changing values) breaks the eager computation's assumptions; keep reducers pure.
Effects: objects, tags, and timing
An effect is an object on the hook (and on the fiber's effect list) with a tag saying what kind it is and whether it fires this commit, a create function, the destroy function returned last time, and the deps. Render decides which effects fire (deps compared by Object.is); commit runs them in the pass their tag assigns. The timing table is the part of React most worth memorising.
useInsertionEffect: mutation pass, before DOM mutations. For injecting styles (CSS-in-JS libraries). Cannot read refs (not attached yet) or set state.useLayoutEffect: layout pass, after DOM mutations and ref attachment, before paint. Synchronous. For measuring and synchronous DOM adjustment; asetStateinside causes an immediate sync re-render and second commit before the frame is shown.useEffect: passive, after paint, in a later task (or flushed earlier if a sync render needs them out of the way). For subscriptions, fetches, logging, and anything that need not be visible in the first frame.- Cleanup order: within a flush, all cleanups (for fibers whose deps changed or that unmounted) run before any callbacks, child before parent. A layout cleanup runs in the mutation pass before the DOM node is removed; a passive cleanup runs in the passive flush.
[]: fire on mount (and cleanup on unmount) only. No array: fire every commit.[a, b]: fire when any element changed byObject.issince the last fire.- Objects and functions in deps change identity every render unless stabilised (
useMemo,useCallback, the Compiler) or hoisted. A dep on an inline object fires the effect every render. - The lint rule (exhaustive-deps) computes the set of values the effect reads from the component scope; omitting one means the effect sees a stale closure. The fix is to include it, restructure so the effect does not need it (a ref for the latest value; an updater function for state), or use
useEffectEvent(React 19.2) for the non-reactive parts of an effect. - Stale closures: an effect's callback captures the variables of the render that created it. An effect with
[]that readscountsees the mount-time count forever. Not a bug in React; a property of closures (the JS course part 8).
- A
useEffectthat reads state A and sets state B schedules a Default-lane render after paint; a second effect reading B and setting C schedules another; the user sees A, then B, then C across three frames. Derive B and C during render (or in the event handler that changed A) and the whole thing is one render. Part 11 has the catalogue.
Refs, memo, and the rest
// useSyncExternalStore: subscribe to something React does not own, without tearing
const width = useSyncExternalStore(
cb => { window.addEventListener('resize', cb); return () => window.removeEventListener('resize', cb) }, // subscribe: called in a passive effect
() => window.innerWidth, // getSnapshot: called in render; must be cached/stable for equal state
() => 1024 // getServerSnapshot: for SSR + hydration
)
// tearing: a concurrent render yields mid-tree; the store changes; the rest of the tree renders with a different value → two parts disagree.
// uSES: records the snapshot at render; in commit, re-reads; if different, forces a synchronous re-render so the committed tree is consistent.
// a plain useEffect + useState subscription can tear (the effect subscribes after paint; the update is a default-lane render).
// use(): read a promise or a context in render (React 19)
const user = use(userPromise) // suspends (throws the promise to the nearest Suspense boundary, part 5) until resolved; the promise must be stable across renders (cache it)
const theme = use(ThemeContext) // like useContext, but allowed after an early return or inside a condition (it is not a hook in the list sense)
// useId: a stable id from the fiber's tree path, identical on server and client (no counter, no randomness)
const id = useId() // ":r1:" style; for aria-labelledby, htmlFor, and anything that must match across hydration
// useActionState / useOptimistic / useFormStatus: part 9 (forms). useTransition / useDeferredValue: part 3. useImperativeHandle: part 0's hatch.useRef: one{ current }object per fiber, created on mount, returned unchanged every render. Mutatingcurrentdoes not render. Two uses: holding a DOM node (attached in the layout pass; null during render) and holding a mutable value across renders (a timer id, the latest callback, a "mounted" flag).- Callback refs (
ref={node => …}) are called with the node on attach and null on detach; a new function each render means detach + attach each render (stabilise withuseCallbackif the callback does work). React 19 lets a callback ref return a cleanup. refon a function component is a normal prop in React 19 (noforwardRefneeded);useImperativeHandle(ref, () => api, deps)setsref.currentto a custom object in the layout pass.
useMemo(fn, deps): stores[value, deps]; recomputes when deps differ byObject.is. A cache keyed by one entry. Purpose: skip expensive computation, or keep a reference stable for a memo'd child or a dep array. React documents that it may discard the cached value (it does not today, but the contract is "performance hint").useCallback(fn, deps):useMemo(() => fn, deps). Only useful when the function's identity matters downstream (a memo'd child, a dep array). On its own it is a small cost (a hook node and a compare) for no benefit.- The Compiler inserts the equivalent of both automatically, from a dependency analysis of each value the component produces, and removes the need to reason about them in most components. Where it cannot (components that break the rules), it skips the component and the manual hooks still apply.
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot): the right way to read anything outside React (a store,windowproperties, a browser API) without tearing under concurrent rendering.getSnapshotmust return a stable value for an unchanged store (cache it) or it loops.use(promise | context): reads a promise by suspending (part 5) or a context; allowed conditionally. The promise must be the same object across renders (cache it outside render) or every render suspends anew.useId: an id derived from the fiber's position in the tree, identical on server and client. For accessibility attributes that must match across hydration. Not for list keys.useDeferredValue,useTransition: part 3.useActionState,useOptimistic,useFormStatus: part 9.useDebugValue: a label for DevTools in custom hooks.
Reading hooks: tools, and the rules restated
| Question | Tool | What to read |
|---|---|---|
| What is this component's hook state right now? | React DevTools → Components → select → "hooks" section (names from the source via the parse hook names feature) | Each hook in list order with its value; the order is the list |
| Which hook changed to cause this render? | Profiler → "Why did this render?" → "Hooks changed: 2, 5" | Indices into the list; map them to the calls in order |
| Is this effect firing every render? | A console.count in it; Profiler commit count; the exhaustive-deps lint | An unstable dep (inline object, function, array) |
| Is a useMemo actually saving anything? | Profiler render durations with and without; the Compiler playground's output | Often nothing; memo matters for references into memo'd children, not for small computations |
| Why "Rendered fewer/more hooks than expected"? | The stack trace; the rules-of-hooks lint | A conditional, loop, early return, or a hook called from a non-component function |
| Why does my effect see an old value? | Reason about the closure: which render created this callback; the deps array | Stale closure; add the dep, use a ref for the latest value, or useEffectEvent |
| Is Strict Mode double-invoking something impure? | Dimmed duplicate logs in the console; effects running twice on mount | Expected in dev; the bug is whatever broke because of it |
| Is this external read tearing? | Inconsistent values across the tree after a transition; switch to useSyncExternalStore and compare | A store read via useEffect + useState during concurrent rendering |
- Same order every render → the list is positional.
- Only from components and custom hooks → the dispatcher is set only during
renderWithHooks; elsewhere it throws. - Render must be pure → render may run twice, be interrupted, or be discarded;
useMemomay be dropped. - Effects must clean up → they re-run when deps change and Strict Mode runs them twice; a cleanup that does not undo the create leaks or double-subscribes.
- Deps must be exhaustive → the effect's closure is from the render that created it; missing deps are stale reads.
- Do not read refs during render →
ref.currentis set in commit; render may run before, after, or without a commit. - External stores through useSyncExternalStore → anything else can tear under concurrent rendering.