Skip to content

Back to Blog Listing

React Fiber Tree: The Virtual DOM

Part 1 of the React Internals series. React stores UI work in fibers, schedules updates through queues and lanes, reconciles current and work-in-progress trees, and preserves state through keys and hook order.

Ben Houston10 min read

This is Part 1 of the React Internals series. It will make the most sense after React Prerequisites, which defines render, commit, paint, effects, and the public hooks used here.

React lets your component return a description of the UI. The reconciler turns that description into work. A renderer later applies the finished work to the browser DOM, native views, or another host.

Props, state, and context flow into the reconciler, which produces host operations for a renderer

Fibers are the data structure React uses for that work. They explain why keys preserve list state, why hooks must keep their order, and why React can prepare the next screen without mutating the screen the user already sees.

The Problem React Solves#

Without React, application code must keep a displayed tree synchronized by hand. It must know which nodes already exist, which values changed, which children moved, which subscriptions need cleanup, and which browser-managed state should survive.

React moves that bookkeeping into the reconciler. Your component receives props, state, and context, then returns elements. React compares those elements with the previous render and records what the host renderer must do at commit time.

React could delete and recreate the whole interface after every update, but host nodes carry identity. Replacing an unchanged input can lose focus or selection. Replacing a video element can interrupt playback. Recreating unchanged DOM also makes the browser repeat parsing, layout, and paint work.

React instead preserves existing host nodes where it can. The question becomes: how does React remember enough about the previous UI to reuse it?

Fibers Are React's Work Nodes#

People often describe React as keeping a "Virtual DOM" in memory. That phrase points in the right direction, but the useful structure is the fiber tree.

A fiber corresponds to one component instance or host element, such as ProductPage, div, View, or mesh. Each fiber stores the information React needs to render, pause, resume, and commit work for that part of the UI.

A fiber includes:

  • The component or host element type.
  • The pending and memoized props.
  • The memoized state for that fiber, including the first hook record for a function component.
  • An update queue for pending state changes.
  • Pointers to child, sibling, and return fibers.
  • An alternate pointer to the paired fiber in the other tree.
  • Lanes and flags that say which work is pending and which host operations commit must perform.

Component tree shown beside the same fibers connected by child, sibling, and return pointers

The tree is still a tree. React stores it with pointers that support a depth-first walk. child points to the first child. sibling points to the next child at the same level. return points back to the parent on every child fiber, not to the previous sibling.

That return pointer is what lets React backtrack without relying on the JavaScript call stack. React can walk to a child, continue through siblings, return to the parent, and later resume from a specific fiber. It does not need to push every ancestor onto a separate userland stack for normal traversal because the back pointer already lives in the fiber.

This pointer structure sets up scheduling. A recursive render wants to finish once it starts. A fiber walk can stop between units of work and continue later.

A State Update Enters a Queue#

Consider a counter:

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

When the click handler calls setCount, React does not mutate count immediately. It creates an update object, attaches that update to the hook queue for the Counter fiber, assigns priority information to the update, and schedules the root that owns the fiber.

A setState call enters a hook queue, marks lanes on ancestor fibers, and schedules the root

React tracks pending work up the tree. Starting from the Counter fiber, React follows return pointers until it reaches the host root for that React tree. In a typical app, that is the root created by createRoot or hydrateRoot. If a page has multiple React roots, each root schedules its own tree.

That scheduling walk is proportional to tree depth, so calling setState includes an O(depth) ancestor-marking step. The depth is usually small compared with the render work React may later do. The payoff is that React does not need to scan the whole tree looking for dirt.

Lanes exist in several related places:

  • The update carries a lane for this state change.
  • The hook queue stores pending updates for that hook.
  • The fiber records lanes for work on itself.
  • Ancestor fibers record child-lane information so React knows that a descendant has work.
  • The root tracks pending lanes for the whole React tree and is what React schedules.

A simplified state update looks like this:

type Lane = number;

type Update<State> = {
  lane: Lane;
  action: State | ((previous: State) => State);
  next: Update<State> | null;
};

type UpdateQueue<State> = {
  pending: Update<State> | null;
};

React keeps the real queue as a linked structure so it can append updates and later process the updates whose lanes match the current render. Each setter call creates its own update object because React still has to preserve the order of state changes:

setCount(count + 1);
setCount(count + 2);
setCount((previous) => previous + 1);

Those three calls are three queue entries, not one merged entry. What React avoids is building a second tree-wide list of those same updates. The root tracks lane sets that summarize which priorities have pending work:

type FiberRoot = {
  current: Fiber;
  pendingLanes: Lane;
  suspendedLanes: Lane;
  pingedLanes: Lane;
};

type Fiber = {
  lanes: Lane;
  childLanes: Lane;
  return: Fiber | null;
};

Those shapes are simplified, but the placement matters. The update object lives near the hook that owns the state. The root tracks which priorities are pending. Fibers between them carry enough lane information for React to navigate to the affected work.

Lane marking is a bitwise operation. If a fiber already has the same lane marked, marking it again leaves the value unchanged. That makes repeated same-priority updates cheap to summarize, but it does not mean React erased the individual hook updates. The hook queue still keeps them so the next render can apply each action in order.

Batching happens at the scheduling and rendering level. Several updates from the same event handler can share priority and produce one scheduled render instead of several separate render-and-commit cycles. Internally, React may still enqueue each update and walk toward the root for each dispatch. The win is that the later render can process the batch together, not that setState calls become free.

During the next render, React processes the queue for the relevant hook. It starts from the last committed state, applies queued updates that match the current render priority, and stores the resulting state on the work-in-progress fiber.

Current and Work-In-Progress Trees#

React keeps two versions of the fiber tree during a render.

The current tree represents the last committed UI. It matches what the user sees. The work-in-progress tree represents the next UI React is preparing. Paired fibers point at each other through alternate, and the relationship goes both directions.

Current and work-in-progress fiber trees linked by alternate pointers with a root current pointer swap at commit

This is double buffering. React can build the work-in-progress tree without mutating the current tree. If React abandons that render, the current tree and host UI remain intact. If React finishes the render, commit applies the host changes and the root's current pointer moves to the finished tree.

React does not mix arbitrary subtrees from two complete trees after every small update. It prepares work for the affected tree, reuses existing fibers where it can, and makes the finished tree current after commit. Unchanged branches may share work through their alternates and copied fields, but the committed root points at one coherent current tree.

This explains why render must stay pure. React can start work on a work-in-progress tree, pause it, discard it, and try again. Side effects in the component body would run during work that might never commit.

How React Reconciles Children#

Reconciliation is React's comparison step. React does not compute a perfect tree edit distance. Arbitrary tree edit distance is too expensive for UI updates, so React uses rules that match how components are usually written.

React reconciliation flow for type changes, in-place updates, and keyed child matching

The rules are practical:

  1. Different element types replace the subtree. If a <div> becomes a <span>, or Chart becomes Table, React treats the old subtree below that point as gone. State below that point resets.
  2. Matching element types update in place. React compares props and children, then records host mutations for commit.
  3. Keys preserve identity in lists. React uses keys to match children across renders when their positions change.

Keys matter because state belongs to fibers. Without keys, React matches list items by position. If you insert a row at the top, state can stick to the old position and appear under the wrong row. With stable keys, React can match the same logical item after it moves.

Array indexes make poor keys for reorderable lists because the index identifies a position, not an item.

Hooks Live on the Fiber#

Hooks look like function calls, but React stores their state on the component's fiber.

For a function component, memoizedState points to the first hook record. The hook records form a linked list. On each render, React walks that list in call order. useState does not look up state by variable name. It asks for the next hook slot.

Function fiber pointing to ordered hook slots with a skipped conditional hook misaligning later slots

A simplified hook record looks like this:

type Hook = {
  memoizedState: unknown;
  queue: UpdateQueue | null;
  next: Hook | null;
};

The mental model for useState is:

function useState<T>(initial: T): [T, Dispatch<T>] {
  const hook = getNextHookSlot();

  if (isMounting) {
    hook.memoizedState = initial;
    hook.queue = createUpdateQueue();
  }

  const state = applyPendingUpdates(hook.memoizedState, hook.queue);
  hook.memoizedState = state;

  return [state as T, dispatchSetState.bind(null, hook.queue)];
}

Real React handles lanes, eager state, skipped updates, render-phase updates, and more edge cases. The important part is the positional storage model.

That storage model explains the rules of hooks. If you call a hook inside a conditional, React sees a different hook sequence on different renders:

if (isLoggedIn) {
  const [name, setName] = useState('');
}

When isLoggedIn changes from true to false, every later hook shifts by one slot. A later useEffect might read the slot that held a useRef. React cannot recover because the hook state has no names attached to it. The order is the identity.

Refs and Effects Use Hook Slots Too#

useRef stores a stable object in a hook slot:

const ref = { current: initialValue };

React returns the same object on later renders. Updating ref.current does not schedule a render because no update queue fires. The ref is mutable state attached to the fiber, not a render trigger.

useEffect also uses a hook slot. During render, React stores the next effect callback and dependency array. After commit, React can compare the new dependencies with the previous committed dependencies using Object.is. If a dependency changed, React runs the previous cleanup, then runs the next callback in the passive effects phase.

That comparison is where immutability and identity meet effects. A new object identity in a dependency array counts as a change, even if its fields look the same.

Next Up: Scheduling#

Fibers give React units of work. Update queues tell React where state changed. Alternates let React prepare a finished tree before it commits. Keys and hook order preserve identity inside that tree.

The next question is how React chooses which work to run first, when it can pause, and how Suspense fits into the same machinery. That is the subject of React Scheduling.