Back

How React Hooks Work

June 25, 2026 (3w ago)

An in-depth look at why hooks are a linked list, how useState queues updates, when effects run, and what the Rules of Hooks are really protecting.

Hooks stored as a linked list on a fiber

Hooks look like ordinary function calls. That is the point — and also the trap.

function Counter() {
  const [n, setN] = useState(0);
  useEffect(() => {
    document.title = `clicked ${n}`;
  }, [n]);
  return <button onClick={() => setN((v) => v + 1)}>{n}</button>;
}

Nothing in that syntax says “store this on a fiber” or “run this after paint.” Yet that is exactly what happens. Hooks are an API for attaching state and side effects to a function component without turning it into a class.

This post is about the mechanism underneath: where hook state lives, why call order matters, how updates are queued, and when effects actually fire.


Note: If you are still learning the public API, start with the React hooks docs. This is a deep dive into the model those APIs implement.


The problem hooks solve

Before hooks, reusable stateful logic usually meant one of:

Those patterns worked, but they split related logic across componentDidMount, componentDidUpdate, and componentWillUnmount, and they made composition awkward.

Hooks let a function component own state and effects directly, and let you extract that logic into custom hooks without changing the component tree shape.

The cost is a constraint: React must be able to match “this useState call” to “this piece of stored state” on every render.

Where hook state lives

Function components do not get instances the way classes do. Persistent data has to live somewhere else.

It lives on the fiber.

Each function component fiber has a memoizedState field. For hooks, that field is the head of a linked list of hook nodes:

type Hook = {
  memoizedState: unknown; // current value for this hook
  baseState: unknown;
  queue: UpdateQueue | null; // pending updaters for useState/useReducer
  next: Hook | null; // next hook in call order
};

On the first render, React creates that list as your component calls hooks. On later renders, React walks the same list in the same order and reuses each slot.

That is the whole game:

Call order is identity.

The first hook call always maps to the first list node. The second call maps to the second node. There are no names in the runtime — only positions.

useState update queue

Why the Rules of Hooks exist

The famous rules are not etiquette. They are load-bearing:

  1. Only call hooks at the top level
  2. Only call hooks from React functions (components or custom hooks)

If you do this:

function Broken({ enabled }) {
  if (enabled) {
    useEffect(() => {
      subscribe();
    }, []);
  }
  const [value, setValue] = useState(0);
  return value;
}

then when enabled flips, the position of useState changes. React still walks the list by index. The state that used to belong to useState can get paired with the wrong hook type or the wrong value. The runtime cannot recover from that safely.

Loops have the same problem: a variable number of hook calls means a variable-length list.

Custom hooks are fine precisely because they are just more top-level calls inside the same component render. Their hooks append to the same fiber list in a stable order.

How useState works

useState is a specialized useReducer.

const [n, setN] = useState(0);
// roughly equivalent to:
// const [n, setN] = useReducer((s, a) => (typeof a === "function" ? a(s) : a), 0);

Mount

On first render, React creates a hook node, stores the initial state in memoizedState, and returns [state, dispatch].

The dispatch function is stable. It closes over the fiber and the hook queue, not over a particular render’s state variable.

Update

When you call setN(1) or setN((n) => n + 1), React does not mutate the current render’s n. It:

  1. enqueues an update on that hook’s queue
  2. marks the fiber with update lanes
  3. schedules a re-render

Later, during the next render, React processes the queue and computes the next memoizedState.

function Example() {
  const [n, setN] = useState(0);
 
  function handleClick() {
    setN((v) => v + 1);
    setN((v) => v + 1);
    // one re-render, n becomes previous + 2
  }
 
  return <button onClick={handleClick}>{n}</button>;
}

Functional updates matter because multiple updates can be queued before React renders again. Each updater receives the latest pending state, not a stale render snapshot.

Object identity also matters:

setUser({ ...user, name: "Ada" }); // new object -> re-render
setUser(user); // same reference -> React can bail out

React bails out of re-rendering a component when the next state is Object.is-equal to the previous one (with some caveats around still-running renders).

How useReducer fits

useReducer is the same machinery with an explicit reducer:

function reducer(state, action) {
  switch (action.type) {
    case "inc":
      return { count: state.count + 1 };
    default:
      return state;
  }
}
 
const [state, dispatch] = useReducer(reducer, { count: 0 });

Prefer it when the next state depends on previous state in a structured way, or when multiple event handlers would otherwise sprinkle related setState calls around the component.

Effects: useEffect and useLayoutEffect

State hooks participate in rendering. Effect hooks schedule side effects after React has committed updates to the host tree.

Effect timing relative to paint

useLayoutEffect

Runs in the commit phase, after DOM mutations, before the browser paints. Use it when you must read layout and update the DOM again before the user sees a frame (measurements, focus, scroll restoration).

useEffect

Runs as a passive effect — scheduled after commit, and usually after paint. That keeps expensive subscriptions or network work from blocking the first paint.

A mental model for one effect slot:

// Conceptual
type Effect = {
  create: () => void | (() => void),
  deps: unknown[] | null,
  destroy: (() => void) | undefined,
};

On update:

  1. compare dependency arrays with Object.is on each item
  2. if deps changed (or deps are omitted), schedule cleanup of the previous effect, then the new setup
  3. if deps are unchanged, skip
useEffect(() => {
  const id = subscribe(userId);
  return () => unsubscribe(id);
}, [userId]);

Omitting the dependency array means “run after every commit.” Passing [] means “run after mount, clean up on unmount” — but only if the effect truly needs no reactive values. The exhaustive-deps lint rule exists because stale closures are otherwise easy.

useRef: a mutable escape hatch

const ref = useRef(null);
// ref === { current: null }  (stable object identity)

useRef also stores a hook node on the fiber, but updating ref.current does not schedule a re-render. That makes refs useful for:

A common pattern: keep a mutable latest value without re-subscribing effects.

function useLatest(value) {
  const ref = useRef(value);
  useLayoutEffect(() => {
    ref.current = value;
  });
  return ref;
}

useMemo and useCallback

These hooks also occupy list slots. They store a cached value (and deps) so React can return the previous result when deps are unchanged.

const total = useMemo(() => expensive(items), [items]);
const onSelect = useCallback((id) => select(id), [select]);

They do not make your component “pure” by themselves. They help when:

Blindly wrapping everything often costs more than it saves. Measure first.

Custom hooks are composition, not magic

A custom hook is a function whose name starts with use and that may call other hooks:

function useWindowWidth() {
  const [width, setWidth] = useState(
    typeof window === "undefined" ? 0 : window.innerWidth,
  );
 
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
 
  return width;
}

Internally, React does not invent a new scope for useWindowWidth. Those useState / useEffect calls still append to the calling component’s hook list. That is why the Rules of Hooks still apply inside custom hooks.

Concurrent rendering and hooks

With concurrent features, React may start rendering a tree, pause, or abandon that render when a higher-priority update arrives.

Implications:

This is why “don’t put side effects in render” is not style advice — abandoned renders would otherwise leave half-applied external changes.

A compact mental model

  1. Each function component fiber owns a linked list of hooks.
  2. Hook identity is call order, not names.
  3. useState / useReducer enqueue updates and re-render later; they do not mutate the current render.
  4. useEffect / useLayoutEffect store effect slots and run after commit (layout before paint, passive usually after).
  5. useRef stores a mutable box without scheduling updates.
  6. Custom hooks compose those slots onto the same fiber list.

Once you see hooks as “positional state on a fiber,” the Rules of Hooks, dependency arrays, and stale closures stop feeling mysterious. They are the surface of a very small runtime contract.

Further reading