Under the Hood
Reactinternals

Hooks: state as a linked list on the fiber

A hook call looks like plain function-call magic that somehow remembers state across renders, but the mechanism is concrete — each fiber holds an ordered linked list of hook records, and React matches your Nth hook call to the Nth record purely by call order, which is the one fact behind both how state persists and why the rules of hooks exist.

Hooks: state as a linked list on the fiber

useState is the part of React that feels most like a trick. You call a plain function, get back a value and a setter, and somehow — across a render that threw away every local variable and ran your component function from scratch — the value is still there next time. No object, no class, no this.state, just a function call that remembers. It isn't a trick. Fiber gave every element a persistent object that survives across renders, and hooks are simply data hanging off that object, retrieved by a rule simple enough to state in one sentence: call order.

Where hook state actually lives

Recall from the fiber lesson that every fiber has a memoizedState field. For a host element like a div that field is unused, but for a function component it's the head of a linked list of hook records — one record per hook call in that component, in the order the hooks were called. useState, useEffect, useRef, useMemo, useCallback — every one of them appends one record to this list on first render.

// Illustrative shape of a hook record — not the literal React source.
const hookRecord = {
  memoizedState: null, // this hook's own state: a value, an effect, a ref, whatever
  queue: null,          // for useState/useReducer: pending updates
  next: null,           // pointer to the NEXT hook record in this component
};

A component that calls useState twice and then useEffect once builds a fiber whose memoizedState points at hook record #1 (the first useState), whose next points at record #2 (the second useState), whose next points at record #3 (the useEffect):

function Profile() {
  const [count, setCount] = useState(0);   // builds hook record 0
  const [name, setName] = useState('');    // builds hook record 1
  useEffect(() => {                        // builds hook record 2
    document.title = `${name}: ${count}`;
  });
  return /* ... */;
}

That list is the entire mechanism. State "persists across renders" because it isn't stored in the function's local variables at all — it's stored on the fiber, which is not recreated on every render the way the function's own locals are. The function runs from scratch every time; the linked list it reads from does not.

The matching rule: by position, not by name

Here is the part that makes hooks work at all. React does not know a variable is called count or name — by the time your component runs, hooks are just calls to an imported function with no names attached to the records. So how does the second call to useState in a render find the record that belongs to name and not the one that belongs to count?

By order. React keeps an internal cursor into the current fiber's hook list, starting at the head. Every hook call — whichever hook it is — reads the record the cursor currently points at, then advances the cursor to next. Call #1 in your component gets record #1. Call #2 gets record #2. There is no lookup by variable name, by hook type, or by anything else content-based — only "which numbered call is this, in the sequence of hook calls this render." As long as your component calls the same hooks in the same order every render, call #2 always lands on the record that was built for call #2, and everything lines up.

Why the rules of hooks exist

This is now not a stylistic guideline you memorize — it's a direct consequence of the mechanism above. The rules of hooks — don't call hooks conditionally, don't call them inside loops, don't call them after an early return — exist because skipping or reordering a call desynchronizes the cursor from the list that was built on a previous render.

Walk through the concrete breakage:

function Bad({ loggedIn }) {
  const [count, setCount] = useState(0);      // hook 0

  if (loggedIn) {
    const [name, setName] = useState('');     // hook 1 — but ONLY on some renders
  }

  const [theme, setTheme] = useState('dark'); // hook 1 or hook 2, depending!
  // ...
}

Render while loggedIn is true: React builds three records — hook 0 (count), hook 1 (name), hook 2 (theme). Now suppose loggedIn flips to false on the next render. The component calls useState for count (cursor at hook 0, fine), skips the name call entirely, then calls useState for theme — but the cursor is now sitting at hook record 1, which was built for name, not theme. React hands theme's call the name record: wrong initial value, wrong updates, and every hook after this point in the component is now off by one, silently reading someone else's state. This is exactly why React's own hook-order check exists (and throws in dev when it detects a mismatched hook count between renders) — not because conditionals are stylistically forbidden, but because the linked-list matching has no other way to stay correct.

Inside useState: a value plus a queue of updates

Each useState hook record stores two things: the current value (memoizedState) and a queue of pending updates. Calling the setter — setCount(5) — does not synchronously overwrite that value. It enqueues an update describing the change and schedules a re-render; the value in the record is left untouched until that re-render actually runs. On the next render, before your component body executes, React walks the queue for each useState hook, applies every pending update in order to produce the new current value, and that is what the hook call returns this time.

Two consequences fall directly out of that:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count); // still logs the OLD value — the record hasn't
                         // been updated yet, only an update was queued
  }
  // ...
}

Reading count immediately after calling setCount gives you the old value, because the assignment only happens on the next render's pass over the queue — the current render's count variable was already captured from the record before the click handler even ran.

function handleClick() {
  setCount(c => c + 1); // updater form: queues "add 1 to whatever
  setCount(c => c + 1); // the value is when this runs" — reliably +2
  setCount(c => c + 1); // total across the three enqueued updates
}

And multiple setState calls in the same event handler don't trigger three separate renders — React batches them, running all three queued updates against the same record in one pass before a single re-render happens. Passing a function to the setter (c => c + 1) rather than a fixed value is how you make each queued update depend on the result of the previous one rather than the stale count the closure captured, which is what makes three calls reliably add three instead of racing each other to add one.

The dispatcher: how useState means something different on mount vs update

One more piece of misdirection worth naming: useState is a single imported identifier, but it does different work depending on whether this is the component's first render or a later one. React keeps an internal dispatcher — swapped out depending on render phase — that the useState you call actually delegates to. On mount, the dispatcher's implementation creates a brand-new hook record, appends it to the fiber's list, and initializes it with the value you passed in. On every later render, the dispatcher's implementation instead walks to the existing record at the cursor position and processes its update queue. Same function name in your code, two different underlying implementations selected by which dispatcher is currently active — which is also why calling a hook outside a component (where there is no active dispatcher at all) throws immediately.

useEffect and useLayoutEffect: records that store a function and its deps

useEffect and useLayoutEffect build hook records too, but what they store is different: not a value, but the effect function itself plus its dependency array from this render. After the render finishes — during commit, per render and commit phases — React compares the new deps array against the one stored from the previous render, element by element. If nothing changed, the effect is skipped entirely this time. If something changed, React first runs the cleanup function returned by the previous invocation (if any), then runs the effect function again and stores its new deps for next time. useLayoutEffect is scheduled synchronously right after the DOM mutations commit but before the browser paints; useEffect is deferred to run after paint, so it doesn't block the frame — the same render/commit timing split, just two different points in it that the effect's own hook record is checked against.

useRef, useMemo, useCallback: records too, just holding different things

Every one of these is the same linked-list mechanism with a different payload in the record:

  • useRef(initial) — the record's memoizedState is just { current: initial }, created once on mount and returned as the same object on every subsequent render. Mutating .current doesn't queue an update or trigger a re-render at all — it's a plain mutable box that happens to survive renders because it lives on the fiber, not in the component's local scope.
  • useMemo(fn, deps) — the record stores the last computed value and the deps it was computed from. On each render, React compares deps; if unchanged, it returns the stored value straight out of the record without calling fn again.
  • useCallback(fn, deps) — the same idea as useMemo, specialized to functions: the record stores the function reference from the render where deps last changed, and returns that same reference again when deps haven't changed, rather than the brand-new function your component body would otherwise create every render.

All five hooks — state, effect, ref, memo, callback — are the identical linked-list-of-records mechanism from the top of this lesson. What differs is only what each record holds and what rule decides whether to reuse or replace it.

What this sets up

Hooks are how a component keeps state between its own renders. The next lesson is about when those renders get to happen at all, and what lets React interrupt one render to handle something more urgent. Scheduling and concurrent React picks up from fiber's interruptibility (lesson 4) to explain how React yields to the browser mid-render — reimplementing, inside its own render loop, the same chunk-and-yield idea the event loop module builds for the browser's main thread generally.

Go deeper

  • React docs — useState The setter's batching and updater-function behavior described precisely, from the source that specifies it.
  • React docs — Rules of Hooks React's own statement of the call-order requirement this lesson derives mechanically from the linked-list structure.
  • React source — ReactFiberHooks.js The actual hook-record linked list, the mount/update dispatcher split, and the update queue implementation this lesson describes.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Physically, what is fiber.memoizedState for a function component, and what does each record in it hold?
  2. How does React know that the second useState call in a render corresponds to the second hook record, given that hook calls carry no names?
  3. Walk through the conditional-hook example: if a useState call is skipped on one render, what happens to every hook call after it in that same render?
  4. Why does reading a state variable immediately after calling its setter give you the old value? What is actually stored in the record at that moment?
  5. What does the updater form of a setter (c => c + 1) fix that setCount(c + 1) does not, when called multiple times in one handler?
  6. What is the dispatcher, and why does the same useState call behave differently on mount versus on a later render?
  7. Contrast what useRef's record holds versus what useMemo's record holds, and explain why mutating a ref never triggers a re-render.