Skip to main content

Two Trees and a Pointer Swap: Double Buffering

What you'll learn

  • React keeps two fiber trees at all times: the current tree and the work-in-progress tree
  • How an update flows: clone what you must, render against the draft, swap one pointer
  • Why untouched branches cost nothing (they're shared, not copied)
  • Why a render can be thrown away with zero visible effect, and why a finished render appears atomically

In the last chapter, React built one fiber tree and kept it. Time for a confession: there are actually two. At every moment, React holds the tree that matches what's on screen and a draft it's preparing, and the entire update machinery is built around flipping between them. This single design choice explains some of React's most magical-seeming behaviors: why a failed or abandoned render never corrupts the screen, and why complex updates appear all at once instead of piece by piece.

The current tree and the work-in-progress tree

Jargon: "current tree". The fiber tree that describes what is actually on screen right now. Its hook cells hold the state you see; its host fibers point at the real DOM nodes you're looking at.

Jargon: "work-in-progress tree". The draft tree React builds while computing an update. Nothing in it is visible. If the work finishes, the draft becomes the current tree. If the work is abandoned, the draft is quietly discarded and the screen never knew it existed.

Jargon: "double buffering". A technique borrowed from computer graphics: instead of drawing the next frame directly on screen (where users would watch it half-drawn), you draw it in an off-screen buffer, then swap buffers when the frame is complete. React applies the same idea to the UI tree: compute off-screen, swap atomically.

The alternate: each fiber has a twin

React doesn't rebuild the second tree from scratch on each update. Every fiber points to its counterpart in the other tree:

Jargon: "alternate". The pointer from a fiber to its twin in the other tree. The current tree's Counter fiber and the work-in-progress Counter fiber are two objects linked as alternates. React flips between them like a two-sided card.

Pseudocode model, not real source:

function getWorkInProgressFiber(currentFiber) {
let draft = currentFiber.alternate;
if (draft === null) {
draft = { ...copyOf(currentFiber) }; // first update ever: create the twin
draft.alternate = currentFiber;
currentFiber.alternate = draft;
} else {
resetInPlace(draft); // reuse the twin: wipe old draft work
draft.memoizedProps = currentFiber.memoizedProps;
draft.memoizedState = currentFiber.memoizedState;
// family links get re-pointed as the draft tree is built
}
return draft;
}

This is why React doesn't allocate a fresh tree every update: the same two sets of objects are reused forever, flip-flopping roles. Allocation happens mostly on the very first updates; after that, it's recycling.

Anatomy of an update, step by step

Say the user clicks a button that calls setOpen(true) in the Content component from last chapter.

  1. The state change is recorded. React writes the pending update into Content's hook cell and marks: "the path from the root down to this fiber needs revisiting."
  2. A draft is started. React walks down from the root. For each fiber it needs to revisit, it takes (or clones) that fiber's alternate into the work-in-progress tree, hook state, props, and DOM pointers copied along.
  3. Untouched branches are shared, not copied. If Header has no pending updates and nothing it cares about changed, React bails out: the draft tree simply points at Header's existing subtree. No cloning, no re-rendering, no work. Re-rendering the root does not mean re-doing every child.
  4. Render runs against the draft. Content's function is called. useState reads the hook cell copied onto the draft fiber and returns true. New elements are produced and diffed against the current tree (next chapter). Changes are noted as flags on the draft fibers, "this text node needs updating", but no DOM is touched yet.
  5. DOM mutations are computed before anything becomes visible. During that same walk, React collects the exact list of real changes the new tree requires (update this text, insert that node). The current tree, what's on screen, is untouched the whole time.
  6. One pointer swap, then commit. The render is complete and consistent. React flips which tree is "current": the draft is now the source of truth. Then the precomputed DOM mutations are applied in one burst (the commit, chapter 5) and the screen changes. The old current tree isn't thrown away, it becomes the draft space for the next update.

From the user's perspective: the screen showed the old UI, then, in one atomic moment, the new UI. There was never an in-between state on screen, because the in-between states only ever existed in the draft.

When is a branch "untouched"?

The bailout in step 3 is not a guess. React shares a subtree only when it can prove nothing inside it could produce different output. Conceptually, a fiber must be revisited if any of these are true:

  1. It has a pending state update, setOpen(true) was recorded on it or below it.
  2. Its parent handed it new elements, the parent re-rendered and produced fresh child elements. (Elements are recreated every render, so "new elements" is the default. Passing the same element reference down, via children props or memoization, is what enables skipping.)
  3. A context it reads changed, the new value invalidates its output (Part 3).

If none of those hold, the subtree's output is guaranteed identical to last time, so the draft just points at the existing fibers. This is why "re-render from the root" is usually fine in practice: the walk is wide but cheap, and unchanged subtrees are shared pointers, not re-computed trees.

Pseudocode model, not real source:

function shouldBailOut(fiber, nextElement) {
if (fiber.hasPendingStateUpdate) return false;
if (fiber.contextChanged) return false;
if (fiber.element !== nextElement) return false; // got a fresh element
return true; // nothing it depends on changed: share the subtree
}

Tracing a toggle through both trees

import { useState } from 'react';

export default function Toggle() {
  const [on, setOn] = useState(false);
  return (
    <button onClick={() => setOn(!on)}>
      {on ? 'ON' : 'OFF'}
    </button>
  );
}

What happens:

  1. Initial mount. The current tree is built: a Toggle fiber (hook cell: false) and a button fiber whose DOM node shows "OFF".
  2. Click. setOn(true) records a pending update on Toggle's hook cell. Nothing on screen changes yet.
  3. Draft begins. React creates Toggle's work-in-progress fiber (the alternate), copying the hook list over. The pending update is applied to the draft's cell: on becomes true in the draft only.
  4. Render against the draft. Toggle() runs; useState returns the draft cell's value, true; the function returns a button element with the text "ON".
  5. Diff. New text "ON" vs the current tree's text "OFF": same position, same kind (text) → keep the node, flag it: "update text to 'ON'". The flag lives on the draft fiber.
  6. Swap and commit. React flips the draft to "current", then applies the precomputed write, conceptually textNode.nodeValue = 'ON' (chapter 5). The screen changes in that one burst.
  7. Next click. The now-old tree is the new draft space. setOn(false), same dance, other direction.

The moment to replay mentally is step 5 → 6: by the time anything visible changes, all decisions are already final. The DOM write and the pointer swap are a formality.

Why this design? Throwaway renders and atomic commits

Double buffering buys React two properties it relies on everywhere:

A render can be abandoned at any moment, with zero visible effect. A render might be thrown away because a newer update arrived, because rendering paused for something urgent, or because a component suspended or threw. Since the draft never touched the screen, discarding it is free and invisible. Users never see a half-computed tree.

A completed render becomes visible atomically. Multi-component updates, a button toggling, a list reordering, a badge updating, are all computed in the draft and then revealed together. The screen can't show you the list's new order with the badge's old count. It's all-or-nothing, exactly like a flipped frame buffer.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: React rebuilds the whole fiber tree on every update. Reality: only fibers on revisited paths are cloned into the draft; subtrees with nothing to do are shared by pointer, a bailout that costs nothing.
  • Misconception: React diffs the new JSX against the real DOM. Reality: the diff runs new elements against the current fiber tree; the DOM is only touched in the commit, once, with a precomputed change list.
  • Misconception: The old tree is garbage-collected after commit. Reality: it's kept and recycled as the next update's draft space, each fiber's alternate twin is reused in place.
  • Misconception: A re-render mutates visible state step by step. Reality: all mutation happens at commit, after the draft is complete; a render that never reaches commit changes nothing you can see.
  • Misconception: State updates apply the moment you call setState. Reality: the update is recorded immediately but applied to the draft's hook cells during render, it only becomes real at the swap.
  • Misconception: Two trees means double the memory, including double the DOM. Reality: fibers are small objects, there are only ever two trees no matter how big the app gets, and both trees' host fibers point at the same real DOM nodes. The DOM exists once.

Why it works this way

  • Consistency. Half-applied UI is the classic disease of manual DOM code. Double buffering makes it structurally impossible: the screen only ever reflects a complete render.
  • Interruptibility. Because the draft is private, React can pause work, drop it, or redo it, the foundation of concurrent rendering (Part 4).
  • Cheap reuse. Alternates mean two fixed sets of objects recycled forever, instead of allocating a fresh tree per update.
  • Shared subtrees make bailouts free. "Clone only what you revisit" turns "re-render from the root" from an O(tree) disaster into an O(changed path) routine event.
  • A single flip is easy to reason about. "Which tree is current?" is one pointer. Correctness doesn't depend on carefully sequencing hundreds of micro-mutations.

Try it yourself

  1. Take the Toggle example and add console.log('rendering', on) in the body. Click once: the log appears before the DOM text changes, proof that render (draft) precedes commit (swap).
  2. In React DevTools → Profiler, record a click that updates several components at once. The profiler reports a separate render duration and commit duration for the update, the two halves of the buffer swap.
  3. Make a component throw during a re-render (if (count > 2) throw new Error('boom')) inside an error boundary. Notice the previous good UI is never left half-updated: the abandoned draft is discarded wholesale, and the boundary's fallback appears in one commit.

Recap

  • React always holds two fiber trees: current (on screen) and work-in-progress (the draft).
  • Each fiber's alternate points to its twin in the other tree; both trees are reused forever.
  • Updates clone only the fibers they revisit; untouched branches are shared pointers, that's why re-rendering from the root is cheap.
  • Render, diff, and DOM-change computation all happen against the draft; the screen isn't touched.
  • The swap flips one pointer, then commit applies the precomputed mutations, the new UI appears atomically.
  • Abandoned renders are invisible by construction: drafts never reach the screen.

Next

Diffing, step by step →