Skip to main content

Hooks: The Linked List Under the Hood

What you'll learn

  • Where React keeps each hook's data: an ordered list of memory cells on your component's internal node
  • How mount renders create cells and update renders walk them with a cursor
  • What the cells of useState, useEffect, useRef, and useMemo each store
  • A two-render walkthrough you can replay mentally for any component
  • Why this one mechanism explains stable state, stable refs, and the rules of hooks

Last chapter we said state lives on React's internal node. But a real component calls useState twice, useEffect once, maybe useRef too. When your second useState runs, how does React know which value you mean? You never pass a name or a key. The answer is the best-kept non-secret in React: an ordered list of memory cells, matched to your hook calls purely by position. Learn this one mechanism and every hook rule becomes obvious.

One component instance, one ordered list

Jargon: "hook". A function whose name starts with use that plugs your component into React-managed memory or behavior (state, effects, refs, memoization…). It only works while React is rendering your component.

When React first renders a component instance, it attaches a list to the instance's internal node. Every hook call you make during a render gets one cell in that list, the first call gets the first cell, the second call gets the second cell, and so on.

Pseudocode model, not real source:

// Each component instance's internal node carries:
const node = {
type: MyComponent,
props: {},
hooks: [], // ordered list of cells, one per hook call
hookCursor: 0, // resets to 0 before every render
};

That's the entire storage system. No names, no lookup table. Position is identity.

Mount creates cells; updates reuse them

The cursor is the trick. Before each render it resets to 0, and each hook call advances it by one:

Pseudocode model, not real source:

// Conceptually, during a render:
function useState(initialValue) {
const node = currentlyRenderingNode;

let cell;
if (node.hookCursor === node.hooks.length) {
// Mount: no cell at this position yet — create one
cell = { value: initialValue, queue: [] };
node.hooks.push(cell);
} else {
// Update: reuse the existing cell at this position
cell = node.hooks[node.hookCursor];
applyQueuedUpdates(cell);
}

node.hookCursor = node.hookCursor + 1;
return [cell.value, cell.setter];
}

What happens:

  1. Mount: the list is empty, so every hook call appends a new cell, storing its initial value.
  2. Update: the list already exists. Each call reuses the cell at the cursor position, applies any queued updates, and returns the current contents.
  3. The argument you pass (useState(0)) is only read in step 1. On updates it's evaluated and ignored, the cell already has a value.

The only input deciding which cell you get is where in the render your call happens.

What's inside each kind of cell

Different hooks store different things, but they all rent space in the same list:

  • useState cell, { value, queue }: the current value plus the pending update requests from the last chapter.
  • useEffect cell, { create, cleanup, deps }: your effect function, the cleanup it returned (if any), and the dependency array from the previous render.
  • useRef cell, { current }: a single mutable box. The ref object you receive is the cell, same identity every render.
  • useMemo cell, { value, deps }: the cached computation and the dependencies it was computed against.

Two renders, step by step

Watch the cursor walk this component:

import { useEffect, useRef, useState } from 'react';

function Stopwatch() {
const [seconds, setSeconds] = useState(0); // hook 0
const inputRef = useRef(null); // hook 1
useEffect(() => { // hook 2
document.title = 'Seconds: ' + seconds;
}, [seconds]);

return (
<div>
<p>{seconds}s</p>
<input ref={inputRef} />
<button onClick={() => setSeconds(s => s + 1)}>+1</button>
</div>
);
}

What happens, render 1 (mount):

  1. Cursor resets to 0.
  2. Call useState(0) at cursor 0: no cell exists → create { value: 0, queue: [] }. Return [0, setSeconds]. Cursor → 1.
  3. Call useRef(null) at cursor 1: create { current: null }. Return it. Cursor → 2.
  4. Call useEffect(...) at cursor 2: create { create, cleanup: undefined, deps: [0] }. The effect itself is scheduled to run after commit and paint. Cursor → 3.
  5. The list is now [stateCell, refCell, effectCell].

You click +1. A functional update lands in the state cell's queue, and a render is scheduled.

What happens, render 2 (update):

  1. Cursor resets to 0.
  2. Call useState(0) at cursor 0: a cell exists → the 0 is ignored, the queued update s => s + 1 is applied → value becomes 1. Return [1, setSeconds]. Cursor → 1.
  3. Call useRef(null) at cursor 1: a cell exists → return the same { current: null } object. Cursor → 2.
  4. Call useEffect(...) at cursor 2: a cell exists → compare new deps [1] with stored deps [0] → changed, so schedule the old cleanup (none) and the new create. Cursor → 3.
  5. After commit and paint, the new effect runs and sets the title to Seconds: 1.

Notice what never mattered: the names seconds and inputRef exist only inside your function. React tracked positions 0, 1, 2, nothing else.

This one mechanism explains everything so far

  • Why state is stable between renders: the cell lives on the node, not in your function. Your function re-runs; the cell doesn't.
  • Why setters and refs have stable identity: they belong to the persistent cell, so React can hand you the same objects every render, which keeps effects and memoized values from thrashing.
  • Why two instances never share: each instance has its own node, hence its own list.
  • Why hooks can't be conditional: if a call were skipped on some render, every later call would shift by one position and read the wrong cells. That's the next chapter's whole story.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: Hooks are matched to state by variable name. Reality: by call order only, the name seconds never reaches React.
  • Misconception: useState(0) resets the state to 0 on every render. Reality: the argument is read only when the cell is created at mount; later renders reuse the cell and apply its queue.
  • Misconception: useRef creates a new object each render. Reality: the ref object is the cell, identical across renders, which is exactly why mutating ref.current never triggers a render.
  • Misconception: React stores hooks on the component function. Reality: on the instance's internal node, two instances, two independent lists.
  • Misconception: The list is an exotic internal detail you can ignore. Reality: it is the mechanism, state stability, ref stability, and the rules of hooks all follow from it.

Why it works this way

  • A list plus a cursor is the smallest possible identity system. No keys, no names, no registration boilerplate, your code reads like plain function calls.
  • Stable cells make stable identities cheap. Setters, refs, and memoized values can be reused across renders without re-subscribing or re-computing.
  • Interleaving comes for free. Any function called during render, including custom hooks, appends to the same list, so logic composes without any special wiring.
  • The price is order-sensitivity. Correctness depends on running the same calls in the same order every render, which is exactly what the rules of hooks protect.

Try it yourself

  1. In Stopwatch, log inputRef on every render and click +1 a few times. In DevTools, confirm the printed object is the same object every time, the persistent cell.
  2. Add a second useState to Stopwatch (say, a label). Sketch the resulting four-cell list on paper, then verify your sketch by logging both values each render.
  3. Swap the order of the useRef and useEffect calls in the source and re-run. Nothing breaks, consistent order is what matters, not which comes first.
  4. Mount two <Stopwatch /> instances. Click one. Reason through which node's list changed and why the other is untouched.

Recap

  • Each component instance's internal node holds an ordered list of hook cells plus a cursor that resets to 0 before every render.
  • The Nth hook call in a render always maps to the Nth cell, position is identity.
  • Mount: cells are created and initial values stored. Update: cells are found by position, queues applied, contents returned.
  • useState cell = value + update queue, useEffect cell = create + cleanup + deps, useRef cell = the stable { current } box, useMemo cell = value + deps.
  • State stability, setter stability, and ref stability are all the same fact: the cell persists.
  • If the call order ever changed between renders, the cursor would read the wrong cells, welcome to the rules of hooks.

Next

The rules of hooks, explained →