Home / Articles / Why the Rules of Hooks Exist: Fiber, Hook Lists and Dispatchers

This article is published in English.

Why the Rules of Hooks Exist: Fiber, Hook Lists and Dispatchers

A tour of React internals: Fiber nodes, double buffering, lanes, the hook linked list and how each built-in hook family stores its state and schedules work.

2852 words

Most React developers can recite the Rules of Hooks, but far fewer can explain why breaking them corrupts state instead of simply throwing a helpful error. The answer lives in React's internals: every hook call becomes a node in a linked list stored on a Fiber, and React finds each node purely by the order in which hooks are called. This guide walks through Fiber, the hook object, the dispatchers React swaps between renders, and the internal mechanics of each hook family, so that the rules stop feeling arbitrary and start reading like consequences of the design.

You will not need this knowledge to write a form or fetch some data. It pays off when you debug a stale value, pick between useEffect and useLayoutEffect, or wonder why a memoized child re-renders anyway.

A quick recap: what hooks replaced and the rules they came with

Hooks arrived in React 16.8, and the built-in set has grown to around seventeen. They addressed three long-standing pain points of class components: stateful logic was hard to reuse across components, related logic was scattered across lifecycle methods until components became bloated, and JavaScript classes themselves (binding this, understanding lifecycles) confused many developers.

With hooks came a set of rules:

  1. Call hooks at the top level of a function component body.
  2. Call hooks at the top level of a custom hook body.
  3. Never call hooks inside conditions or loops.
  4. Never call hooks after a conditional early return.
  5. Never call hooks inside event handlers.
  6. Never call hooks in class components.
  7. Never call hooks inside the callbacks you hand to useEffect, useMemo or useReducer.
  8. Never call hooks inside try, catch or finally blocks.

Violations produce warnings, errors or, worse, subtle bugs. The short explanation is that a component's hooks form a singly linked list hanging off a Fiber node, which is a stateful JavaScript object React keeps for each component. To see why that matters, start with Fiber itself. If you already know it well, jump ahead to the section on the hook object. For a gentler introduction to reconciliation and state, see building a mental model for React reconciliation, state and hooks.

React Fiber: the engine hooks live in

Fiber is React's reconciliation engine, introduced in React 16 as a ground-up rewrite of how React computes and applies UI updates.

The problem with the old stack reconciler

Before Fiber, React used what is often called the stack reconciler. On every update it walked the component tree recursively, and a recursive walk on the JavaScript call stack cannot be paused halfway: once it starts, it runs until the whole tree is processed. On a large tree that monopolized the single main thread, so animations froze, keystrokes lagged and the interface stuttered. Worse, there was no way to let an urgent update, such as a keypress, jump ahead of a large, less important render that was already underway.

What Fiber makes possible

A fiber is a plain JavaScript object representing one unit of work, tied to a component instance or a DOM node. Because React tracks these units itself instead of relying on the call stack, it gains three abilities:

  • Pause and resume. React can stop in the middle of a tree, let the browser handle something more urgent such as user input, and continue later from the same spot.
  • Prioritize. Urgent updates can overtake less important ones.
  • Reuse or discard. If the user navigates away while a render is in flight, the unfinished work can simply be thrown away.

The shape of a Fiber node

Every React element, whether a component, a host DOM element or a text node, gets a matching Fiber. It is a large object holding the component's props, its state and a link to its DOM representation.

Rather than storing children in arrays, fibers form a tree through three pointers:

  • child leads to the fiber's first child.
  • sibling leads to the next fiber at the same level.
  • return leads back up to the parent.

Processing an update means following these links: descend through child as far as possible, move sideways through sibling, and climb back via return when a branch is finished. Because this is an ordinary loop over pointers rather than recursion, React can stop between any two fibers.

Double buffering with two trees

Fiber borrows double buffering from graphics programming. At any moment React holds two fiber trees in memory:

  • The current tree mirrors exactly what is on screen. React does not mutate it while computing an update.
  • The work-in-progress (WIP) tree is built in the background when something changes. React clones the current fibers that need updating and assembles the new version beside the old one.

When the WIP tree is complete, React flips the root pointer. The WIP tree becomes current, and the screen reflects the new state. Each fiber keeps an alternate pointer to its counterpart in the other tree, which is how hook state is carried from one render to the next.

Render phase and commit phase

This architecture splits every update into two phases.

The render phase is interruptible. React walks the tree, calls your component functions, runs hooks and diffs the result against the current tree while building the WIP tree in memory. Since React owns the traversal loop, its scheduler can yield to the browser every few milliseconds. If the user types while a low-priority render is in progress, React can pause, handle the input and resume. It can even discard the whole WIP tree when a newer, more urgent update makes it obsolete. Because a render may run several times or never commit, component functions must be pure: no side effects during rendering.

The commit phase is synchronous. Once the WIP tree is finished, React applies the computed changes to the real DOM in one go. This step cannot pause, because stopping halfway through DOM mutations would show the user a half-updated, inconsistent interface. Layout effects run here, passive effects are scheduled from here, and refs are attached.

Lanes: how React decides what to interrupt

To know what may interrupt what, React tags every update with a lane. Lanes are represented as bits in a bitmask, which makes combining and comparing priorities cheap. Broadly:

  • A sync lane for discrete, urgent interactions such as clicks and key presses (continuous events like hovering or scrolling get their own high-priority lane).
  • Transition lanes for interruptible work: background updates, data-driven rerenders, switching tabs.
  • Retry lanes for Suspense boundaries that are resolving.

You never touch Fiber directly, yet it underpins the headline features of React 18 and 19. Concurrent rendering, Suspense, useTransition and useDeferredValue all depend on rendering being interruptible.

The hook object and why call order is everything

Function components have no instance to hold state, so React stores it on the fiber in a field called memoizedState. For function components, that field points to the first hook in a singly linked list.

Each hook call during render corresponds to one object shaped roughly like this:

{
  memoizedState: any,        // The internal state of the hook
  baseState: any,            // The state before any unprocessed updates
  baseQueue: Update | null,  // Updates that were skipped due to priority
  queue: UpdateQueue | null, // Circular linked list of pending state updates
  next: Hook | null          // Pointer to the next hook in the component
}

The memoizedState of the hook holds its value (the state for useState, the effect record for useEffect, the cached pair for useMemo), queue holds pending updates, baseState and baseQueue keep track of updates skipped because their lane was not being processed, and next links to the following hook.

Notice what is missing: there is no key or name. On a re-render React simply walks the list from the head, pairing the first hook call with the first node, the second with the second and so on. If a hook is called inside an if and the condition flips, every subsequent call is paired with the wrong node, and state from one hook leaks into another. That is the core reason for the Rules of Hooks: they guarantee the same hooks run in the same order on every render. The rules about loops, early returns, try blocks and callbacks are all variations of the same requirement.

One modern exception confirms the principle: the use API in React 19 can be called conditionally, precisely because it does not rely on a slot in this list in the same way.

Dispatchers: the same hook name, different implementations

The useState you import is a thin wrapper. At runtime it forwards to whichever dispatcher React has installed for the current phase. Older React versions expose this through ReactCurrentDispatcher; in recent versions the dispatcher lives on React's internal shared object, but the idea is identical.

  • HooksDispatcherOnMount is active for the first render. useState maps to mountState, which allocates a fresh hook object, sets its initial state and links it onto the end of the list.
  • HooksDispatcherOnUpdate is active on re-renders. useState maps to updateState, which advances along the existing list (effectively workInProgressHook = workInProgressHook.next), processes the pending queue and returns the new state.
  • ContextOnlyDispatcher is installed whenever React is not rendering a component. Any hook called through it throws, which is where the "invalid hook call" error comes from when you call a hook outside a component.

This design also explains why calling hooks inside event handlers fails: by the time the handler runs, rendering is over and the throwing dispatcher is in place.

How each hook family works internally

All hooks share the linked-list foundation, but they differ widely in what they store and when their work happens.

State hooks: useState and useReducer

Internally useState is useReducer with a built-in reducer that either returns the new value or calls your updater function with the previous one. Both share one execution model:

  • Storage. The hook keeps a base state (the last committed value) and an update queue, which is a circular linked list of pending changes.
  • Dispatch. Calling a setter, for example setCount(c => c + 1), creates an update object holding that action, appends it to the queue and marks the fiber as needing work by assigning a lane (older versions used expiration times for the same purpose).
  • Resolution. During the next render, React walks the queue and applies each action in order to produce the new memoizedState. Updates whose lane is not included in the current render are kept in baseQueue and replayed later, which preserves ordering across priorities.

This is also why updater functions are the safe choice when the next state depends on the previous one: they are applied in sequence against whatever state the queue has computed so far.

Effect hooks: useInsertionEffect, useLayoutEffect and useEffect

Each effect hook stores an effect record in its hook state containing the setup function, the cleanup function and the dependency array. The records are also chained onto a separate list on the fiber's updateQueue, and effects whose dependencies changed are flagged so the commit phase knows which ones to run. The three hooks differ in timing:

  • useInsertionEffect runs before layout effects, ahead of any code that might read layout. It exists for CSS-in-JS libraries that need to inject <style> rules early so that styles are in place when layout is measured, avoiding repeated style recalculation.
  • useLayoutEffect runs synchronously once React has changed the DOM, yet before the browser gets a chance to paint. The main thread is blocked until the effect and its cleanup finish, so it suits measuring and adjusting DOM nodes before the user sees anything, and nothing heavier.
  • useEffect is passive. It normally runs after the browser has painted, scheduled through React's scheduler (which uses MessageChannel, falling back to setTimeout), so it does not delay the visual update. Be aware that when an update comes from a discrete user input, React may flush passive effects before paint.

Performance hooks: useMemo and useCallback

These are caches that skip expensive recomputation or keep references stable between renders.

  • Storage. The hook stores a pair: the cached value and the dependency array it was computed with.
  • Execution. On re-render, React compares each new dependency with the cached one using Object.is. If all match, the factory is not called and the cached value is returned. If any differ, React calls the factory, stores the new value and dependencies, and returns the result.
  • useCallback is equivalent to useMemo(() => fn, deps): it keeps the function object you passed rather than a value produced by invoking it. React's source implements it separately, but the behavior is the same.

Because the comparison is shallow, a dependency that is a freshly created object or array on every render defeats the cache entirely.

Mutable value hooks: useRef and useImperativeHandle

Refs hold information that is not used for rendering, such as a DOM node or a timeout ID.

  • useRef is about the simplest hook in the codebase. On mount it creates { current: initialValue } and stores it as the hook's state; every later render returns the very same object. Writing to current never touches the update queue or lanes, so it never triggers a render.
  • useImperativeHandle customizes what a parent sees through a ref by attaching your own methods to it. Internally it behaves like useLayoutEffect: it runs synchronously during commit, so the handle is ready by the time the parent's effects run. In React 19, function components can receive ref as a regular prop, so forwardRef is no longer required to use it.

The context hook: useContext

useContext stands out because it never occupies a slot in the hook list.

  • Reading. It reads the value from the nearest matching Provider above the component and records that context in the fiber's dependency list.
  • Propagating. When a Provider's value changes, React searches below it for fibers whose dependencies include that context and schedules them to re-render. This happens even if an intermediate component bails out via React.memo or shouldComponentUpdate, which is why memoizing a parent does not shield context consumers.

Concurrent hooks: useTransition and useDeferredValue

This pair is your handle on the lane system, letting long renders be interrupted.

  • useTransition returns [isPending, startTransition]. Updates made inside startTransition(() => setQuery(text)) are assigned a transition lane instead of an urgent one. If a click or keystroke arrives while the transition renders, React abandons the WIP tree, handles the urgent update and then restarts the transition from scratch.
  • useDeferredValue wraps a value rather than a setter. React effectively keeps two versions: it first renders with the previous value so the screen stays responsive, then schedules a low-priority background render with the new one.

Specialty hooks: useId and useSyncExternalStore

Some hooks rarely appear in application code but are essential for library authors.

  • useId prevents hydration mismatches in server-side rendering. It derives an ID from the component's position in the tree. Since the tree shape is the same on server and client during the initial hydration, the IDs match without any global counter.
  • useSyncExternalStore replaces hand-written useEffect subscriptions to external stores such as Redux or Zustand. You give it two functions: one that registers a change listener on the store, and getSnapshot, which returns the store's current value. React reads the snapshot during render and, if the store changes mid-render, re-renders synchronously so that no part of the UI shows a different version of the store than another. This prevents the tearing that concurrent rendering could otherwise cause.

Key takeaways

  • Hook state is a linked list on the fiber, matched by call order alone; every Rule of Hooks exists to keep that order identical across renders.
  • Fiber turns rendering into an interruptible loop, and double buffering lets React prepare a new tree without touching what is on screen.
  • The render phase may run many times and must stay pure; the commit phase runs once, synchronously, and is where effects are triggered.
  • Dispatchers explain both the mount/update split and the error when a hook is called outside rendering.
  • Knowing where each hook stores its data, and when its work runs, makes it easier to choose the right effect timing, keep memoization effective and reason about context and concurrent updates.