Part 4 · 5 chapters · ~45 min

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.

20

The hook list

the question

"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.

code
// 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 nothing
the machinery
  1. renderWithHooks(current, wip, Component, props): set currentlyRenderingFiber; choose the dispatcher (mount if current === null or no hooks yet, else update); call Component(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").
  2. Mount dispatcher: each hook allocates a node and appends; initialisers run; dispatch functions are created and bound to the fiber and queue (so setState is referentially stable forever).
  3. 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.
  4. Re-render dispatcher: a setState during 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".
what follows
  1. Order is identity; hence no conditions, loops, or early returns before hooks. The ESLint rule enforces it; the Compiler relies on it.
  2. 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).
  3. setState and dispatch are stable (bound once at mount), which is why they can be omitted from dependency arrays.
  4. Initialisers run on mount only (useState(() => expensive())); on update the argument is ignored entirely.
THE HOOK LIST
memoizedState is a linked list, walked in call order
swipe the figure sideways, or tap expand for full screen
1/6
five calls
function Form() { const [name, setName] = useState(""); const [open, setOpen] = useState(false); useEffect(sync, [name]); const ref = useRef(null); const total = useMemo(calc, [name]) }. Five calls.
21

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.

dispatchSetState(fiber, queue, action)
  1. Lane: requestUpdateLane(fiber) (part 3).
  2. Eager path: if the fiber has no pending lanes (the queue is empty), compute eagerState = lastRenderedReducer(lastRenderedState, action) now; if Object.is to the current state, return without scheduling anything. Otherwise store the eager result on the update so the render can skip recomputing it.
  3. 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.
  4. Schedule: mark lanes up the tree; ensureRootIsScheduled.
updateReducer, in the render
  1. Merge: append the pending list to the hook's base queue (from a previous render's skipped updates).
  2. 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, record baseState as 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.
  3. Finish: memoizedState = the computed state (what this render shows); baseState and baseQueue as recorded; if the new state is Object.is to the old, markWorkInProgressReceivedUpdate is not called and the component may late-bail (part 2).
what this explains
  1. "State updates are asynchronous": the queue is processed in the next render; the handler sees the old value.
  2. Updater functions see the latest state because they are applied in order during the walk, each to the previous result.
  3. setX(sameValue) is nearly free: the eager path bails before scheduling.
  4. Batching is the queue holding several updates when the render runs.
  5. 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.
  6. useReducer is 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.
A useState QUEUE, PROCESSED
pending updates, lanes, and the base state
swipe the figure sideways, or tap expand for full screen
1/6
three updates
Three calls between renders: setCount(c => c + 1) in a click (SyncLane), setCount(10) inside startTransition (TransitionLane 1), setCount(c => c * 2) in another click (SyncLane). queue.pending is a circular list: u3 → u1 → u2 → u3 (pending points at the last; last.next is the first).
22

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.

the kinds
  1. useInsertionEffect: mutation pass, before DOM mutations. For injecting styles (CSS-in-JS libraries). Cannot read refs (not attached yet) or set state.
  2. useLayoutEffect: layout pass, after DOM mutations and ref attachment, before paint. Synchronous. For measuring and synchronous DOM adjustment; a setState inside causes an immediate sync re-render and second commit before the frame is shown.
  3. 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.
  4. 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.
deps, precisely
  1. []: fire on mount (and cleanup on unmount) only. No array: fire every commit. [a, b]: fire when any element changed by Object.is since the last fire.
  2. 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.
  3. 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.
  4. Stale closures: an effect's callback captures the variables of the render that created it. An effect with [] that reads count sees the mount-time count forever. Not a bug in React; a property of closures (the JS course part 8).
the effect-chain anti-pattern, in these terms
  1. A useEffect that 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.
EVERY HOOK'S MOMENT
where each hook runs relative to render, commit and paint
swipe the figure sideways, or tap expand for full screen
1/6
render
Render phase (interruptible, may rerun): useState / useReducer read their queues; useMemo and useCallback compare deps and compute or reuse; useRef returns its node; useContext reads the stack; useSyncExternalStore calls getSnapshot; useDeferredValue returns the old or new value by lane; useId computes from the tree position. No DOM, no subscriptions, no side effects.
23

Refs, memo, and the rest

code
// 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.
refs
  1. useRef: one { current } object per fiber, created on mount, returned unchanged every render. Mutating current does 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).
  2. 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 with useCallback if the callback does work). React 19 lets a callback ref return a cleanup.
  3. ref on a function component is a normal prop in React 19 (no forwardRef needed); useImperativeHandle(ref, () => api, deps) sets ref.current to a custom object in the layout pass.
memoisation hooks
  1. useMemo(fn, deps): stores [value, deps]; recomputes when deps differ by Object.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").
  2. 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.
  3. 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.
the specialised ones
  1. useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot): the right way to read anything outside React (a store, window properties, a browser API) without tearing under concurrent rendering. getSnapshot must return a stable value for an unchanged store (cache it) or it loops.
  2. 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.
  3. 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.
  4. useDeferredValue, useTransition: part 3. useActionState, useOptimistic, useFormStatus: part 9. useDebugValue: a label for DevTools in custom hooks.
24

Reading hooks: tools, and the rules restated

QuestionToolWhat 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 lintAn unstable dep (inline object, function, array)
Is a useMemo actually saving anything?Profiler render durations with and without; the Compiler playground's outputOften 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 lintA 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 arrayStale 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 mountExpected 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 compareA store read via useEffect + useState during concurrent rendering
the rules, as mechanisms
  1. Same order every render → the list is positional.
  2. Only from components and custom hooks → the dispatcher is set only during renderWithHooks; elsewhere it throws.
  3. Render must be pure → render may run twice, be interrupted, or be discarded; useMemo may be dropped.
  4. 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.
  5. Deps must be exhaustive → the effect's closure is from the render that created it; missing deps are stale reads.
  6. Do not read refs during render → ref.current is set in commit; render may run before, after, or without a commit.
  7. External stores through useSyncExternalStore → anything else can tear under concurrent rendering.
the pointer
Part 5 is concurrent rendering as a feature set: transitions in use, Suspense as thrown promises and boundaries, streaming SSR, selective hydration, and tearing. Every one of them relies on the render phase being safe to repeat, which is what the rules above guarantee.