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, anduseMemoeach 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
usethat 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 callhookCursor: 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 onecell = { value: initialValue, queue: [] };node.hooks.push(cell);} else {// Update: reuse the existing cell at this positioncell = node.hooks[node.hookCursor];applyQueuedUpdates(cell);}node.hookCursor = node.hookCursor + 1;return [cell.value, cell.setter];}
What happens:
- Mount: the list is empty, so every hook call appends a new cell, storing its initial value.
- Update: the list already exists. Each call reuses the cell at the cursor position, applies any queued updates, and returns the current contents.
- 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:
useStatecell,{ value, queue }: the current value plus the pending update requests from the last chapter.useEffectcell,{ create, cleanup, deps }: your effect function, the cleanup it returned (if any), and the dependency array from the previous render.useRefcell,{ current }: a single mutable box. The ref object you receive is the cell, same identity every render.useMemocell,{ 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):
- Cursor resets to
0. - Call
useState(0)at cursor 0: no cell exists → create{ value: 0, queue: [] }. Return[0, setSeconds]. Cursor → 1. - Call
useRef(null)at cursor 1: create{ current: null }. Return it. Cursor → 2. - Call
useEffect(...)at cursor 2: create{ create, cleanup: undefined, deps: [0] }. The effect itself is scheduled to run after commit and paint. Cursor → 3. - 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):
- Cursor resets to
0. - Call
useState(0)at cursor 0: a cell exists → the0is ignored, the queued updates => s + 1is applied → value becomes1. Return[1, setSeconds]. Cursor → 1. - Call
useRef(null)at cursor 1: a cell exists → return the same{ current: null }object. Cursor → 2. - 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 newcreate. Cursor → 3. - 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 (PNG hi-res):
Common misconceptions
- Misconception: Hooks are matched to state by variable name. Reality: by call order only, the name
secondsnever 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:
useRefcreates a new object each render. Reality: the ref object is the cell, identical across renders, which is exactly why mutatingref.currentnever 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
- In
Stopwatch, loginputRefon every render and click+1a few times. In DevTools, confirm the printed object is the same object every time, the persistent cell. - Add a second
useStatetoStopwatch(say, a label). Sketch the resulting four-cell list on paper, then verify your sketch by logging both values each render. - Swap the order of the
useRefanduseEffectcalls in the source and re-run. Nothing breaks, consistent order is what matters, not which comes first. - 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.
useStatecell = value + update queue,useEffectcell = create + cleanup + deps,useRefcell = the stable{ current }box,useMemocell = 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.