React Fiber Tree: The Virtual DOM
Part 2 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 Houston • • 11 min read
This is Part 2 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.
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 node creation, style recalculation, 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, andreturnfibers. - An
alternatepointer to the paired fiber in the other tree. - Lanes and flags that say which work is pending and which host operations commit must perform.
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, associates it with the hook queue for the Counter fiber, and assigns a lane to the update. React normally schedules the root that owns the fiber. If React can compute the next state eagerly and it is identical to the current state, it can queue the update without scheduling a render.
For a scheduled update, React follows return pointers from the Counter fiber until it reaches the host root for that React tree. In a typical app, that is the root created by createRoot or hydrateRoot. React also marks lane information on the path so ancestors know that a descendant has work. If a page has multiple React roots, each root schedules its own tree.
The root lookup and ancestor marking are proportional to tree depth. Current React performs them as separate traversals at different points in concurrent queue processing. 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. React may first place references to concurrent updates in a temporary module-level staging array before linking them into their hook queues. It does not maintain that staging array as a second persistent state queue. 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 hook's baseState, applies queued updates whose lanes match the current render, and stores the resulting state on the work-in-progress fiber. baseState can differ from the most recently committed state when an earlier update was skipped at a lower priority; React retains and later rebases that update.
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.
This is double buffering. React can build the work-in-progress result without applying its host mutations to the displayed UI. Scheduling metadata such as lanes can still change on current fibers, and some queue objects are shared between current and work-in-progress fibers. If React abandons that render, the committed result and host UI remain in place. 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.
The rules are practical:
- Different element types replace the subtree. If a
<div>becomes a<span>, orChartbecomesTable, React treats the old subtree below that point as gone. State below that point resets. - Matching element types update in place. React compares props and children, then records host mutations for commit.
- 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.
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, currentlyRenderingFiber, 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 would consume the slot that previously belonged to another hook. Development builds detect hook-order mismatches and report an error, but React cannot infer the intended mapping because 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 compares the next dependency array with the previous committed dependencies using Object.is. It stores the next callback and dependencies and, when something changed, marks the effect to run. After commit, React runs the previous cleanup and then 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.