1.3 Two trees: double buffering
If React edited the live UI while it was still computing an update, you would catch the screen mid-change — half the old layout, half the new one — and React could never pause a slow render to answer an urgent click. This lesson shows the one trick that prevents both problems: React builds every update on a private copy and only reveals it when it is completely finished.
Two complete trees, not one
The common misconception is that React keeps one fiber tree and modifies it in place. The reality: React always maintains two complete fiber trees and alternates between them on every commit.
One tree is the current tree — it represents exactly what is on screen right now. The other is the work-in-progress tree (often shortened to WIP) — the draft React is building in memory. Here is what each one is for:
┌─────────────────────────────────────────────────────────────┐
│ CURRENT TREE (what's on screen) │
├─────────────────────────────────────────────────────────────┤
│ • Represents the UI currently visible to the user │
│ • Points to: root.current │
│ • Never modified during render phase │
│ • Stable reference for comparisons │
└─────────────────────────────────────────────────────────────┘
fiber.alternate ↕ fiber.alternate
(bidirectional link)
┌─────────────────────────────────────────────────────────────┐
│ WORK-IN-PROGRESS TREE (what we're building) │
├─────────────────────────────────────────────────────────────┤
│ • Represents the UI being rendered in memory │
│ • Points to: workInProgress variable │
│ • Modified during render phase │
│ • Can be safely abandoned if interrupted │
└─────────────────────────────────────────────────────────────┘
The root object holds a pointer named root.current that always points at the current tree. A module-level variable named workInProgress points at the draft. Keep those two names in mind — the whole lesson is about how React fills in the draft and then makes root.current point to it.
The alternate link
The two trees are not strangers. Every fiber carries an alternate pointer that connects it to its counterpart in the other tree. The <App> fiber in the current tree and the <App> fiber in the WIP tree point at each other. The link is bidirectional — from either side you can always walk across to the other.
The real code
// ReactInternalTypes.js
export type Fiber = {
// ... other fields ...
alternate: Fiber | null, // ← Points to the OTHER tree's fiber
// ... other fields ...
}
// The relationship:
currentFiber.alternate = workInProgressFiber
workInProgressFiber.alternate = currentFiber
That single field is what makes double buffering cheap. Because each fiber already knows its twin, React can reuse the twin as the next draft instead of allocating a brand-new tree every time — which is exactly what we will see in createWorkInProgress below.
Build the draft, then reveal it with one swap
The lifecycle has three distinct phases — the initial mount, every subsequent render, and the commit-time swap that reveals the draft:
INITIAL MOUNT
Create root fiber → root.current = rootFiber → rootFiber.alternate = null
FIRST RENDER
createWorkInProgress(root.current)
→ allocate new fiber, set workInProgress.alternate = current
and current.alternate = workInProgress
→ build workInProgress tree top-down
→ COMMIT: root.current = workInProgress ← swap!
SECOND RENDER (update)
createWorkInProgress(root.current)
→ current.alternate already exists → REUSE it (no allocation)
→ reset flags, update pendingProps
→ build workInProgress tree top-down
→ COMMIT: root.current = workInProgress ← swap back
Notice the symmetry. The first render has to allocate the second tree, because no alternate exists yet. Every render after that reuses a tree that is already sitting there. And in both cases the reveal is identical: one assignment to root.current at commit.
createWorkInProgress: allocate once, reuse forever
Every render begins with prepareFreshStack. Its job is to set up a clean draft to work on. It hands the real tree-building work to createWorkInProgress and parks the result in the global workInProgress variable.
The real code
// prepareFreshStack — ReactFiberWorkLoop.js
function prepareFreshStack(root, lanes) {
root.finishedWork = null;
root.finishedLanes = NoLanes;
// ===== CREATE OR REUSE WORK-IN-PROGRESS TREE =====
const rootWorkInProgress = createWorkInProgress(root.current, null);
// ↑ clones current tree OR reuses existing alternate
workInProgress = rootWorkInProgress; // global variable points to WIP tree
workInProgressRoot = root;
workInProgressRootRenderLanes = lanes;
return rootWorkInProgress;
}
createWorkInProgress is the heart of double buffering. It has exactly two branches: no alternate yet (first render — allocate a fiber) and alternate exists (every later render — reuse it and reset a few fields). Either way, it then copies the scheduling and state fields across from current so the draft starts from a correct baseline.
The real code
// createWorkInProgress — ReactFiber.js
export function createWorkInProgress(current, pendingProps) {
let workInProgress = current.alternate;
if (workInProgress === null) {
// ===== NO ALTERNATE: CREATE NEW FIBER =====
// First render after mount — allocate
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode,
);
workInProgress.elementType = current.elementType;
workInProgress.type = current.type;
workInProgress.stateNode = current.stateNode;
// Link the two trees (bidirectional)
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// ===== ALTERNATE EXISTS: REUSE IT =====
// Subsequent renders — no allocation
workInProgress.pendingProps = pendingProps;
// Reset flags from previous render
workInProgress.flags = NoFlags;
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
}
// Copy scheduling and state fields from current to WIP
workInProgress.childLanes = current.childLanes;
workInProgress.lanes = current.lanes;
workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
return workInProgress;
}
Read the first line slowly: let workInProgress = current.alternate. The draft React is about to build on is the old tree from two renders ago. On the very first update the alternate is the tree allocated during mount; on every render after that it is whatever tree was on screen before the previous commit. React keeps recycling the same two fiber objects back and forth.
The swap is one line
After the render phase has built the entire WIP tree in memory, and after the commit phase has applied every DOM change, React reveals the new UI with a single pointer assignment:
The real code
// commitRootImpl — ReactFiberWorkLoop.js
function commitRootImpl(root, recoverableErrors, transitions) {
// ... apply all DOM mutations from the workInProgress tree ...
// ===== SWAP THE TREES =====
root.current = finishedWork;
// finishedWork is the completed workInProgress tree.
// What WAS workInProgress is NOW current (visible on screen).
// What WAS current is NOW the alternate (ready for next render).
// ... run effects ...
}
This is the moment the easel and the workbench trade places. The instant root.current points at the finished tree, that tree is the UI — and the tree that was on screen a moment ago becomes the spare alternate, waiting to be reused as the next draft. There is no in-between state where the screen is half-old and half-new.
Show the full annotated timeline of one render cycle
// ===== INITIAL STATE (after first mount) =====
root.current = <App>(current)
├─ <Header>(current)
└─ <Content>(current)
<App>(current).alternate = null // no alternate yet
// ===== T=0ms: user clicks, render starts =====
prepareFreshStack(root, SyncLane)
// allocate workInProgress tree
workInProgress = createWorkInProgress(root.current, null)
// now two trees exist simultaneously:
root.current = <App>(current) // on screen — untouched
↕ alternate
workInProgress = <App>(WIP) // in memory — being built
// ===== T=1ms: render phase =====
workLoopConcurrent()
performUnitOfWork(<App>(WIP))
beginWork(<App>(WIP), renderLanes)
// read from current: current.memoizedProps
// write to WIP: workInProgress.memoizedProps
performUnitOfWork(<Header>(WIP))
beginWork(<Header>(WIP), renderLanes)
const oldProps = current.memoizedProps; // stable, from current tree
const newProps = workInProgress.pendingProps; // being computed, from WIP tree
if (oldProps !== newProps) { /* render */ }
// during render:
root.current (unchanged, stable) <-alternate-> workInProgress (being modified)
// ===== T=5ms: render complete =====
// WIP tree is fully built in memory
// user still sees root.current (old, consistent UI)
// ===== T=6ms: commit phase =====
commitRoot()
// apply all DOM changes described by workInProgress tree
root.current = finishedWork // ← THE SWAP
// ===== AFTER COMMIT =====
root.current = <App>(was WIP, now current) // what user sees
↕ alternate
<App>(was current, now alternate) // old tree, ready for reuse
// ===== NEXT RENDER =====
prepareFreshStack(root, nextLanes)
workInProgress = createWorkInProgress(root.current, null)
// current.alternate already exists — no allocation!
workInProgress = current.alternate // REUSE
workInProgress.flags = NoFlags // reset
workInProgress.pendingProps = nextProps // update
Why two trees? Four payoffs
Carrying a second tree is not free, so React only does it because it buys four concrete things.
1. Safe interruption — the user never sees a half-finished update
Because every change is written to the draft and never to the live tree, the screen is always a complete, consistent UI — even mid-render.
The real code
// Without double buffering — dangerous:
root.current.child = newChild; // user sees half-finished update!
// With double buffering — safe:
workInProgress.child = newChild; // current is untouched; user sees consistent UI
// Only swap when the ENTIRE tree is complete
2. Ability to abandon work
When a higher-priority update arrives mid-render, React discards the in-progress WIP tree and starts fresh from the still-intact current tree:
The real code
// High-priority update arrives during a low-priority render
if (workInProgressRootRenderLanes !== nextLanes) {
// THROW AWAY the workInProgress tree
prepareFreshStack(root, nextLanes);
// ↑ creates a NEW workInProgress from current
// ↑ old workInProgress is garbage collected
// ↑ current tree UNCHANGED — user sees consistent UI
}
Show the interruption, frame by frame
T=0ms: low-priority render starts
root.current (stable) workInProgress (building)
<App> <App>
└─ <List> └─ <List>
├─ Item 0 ├─ Item 0 ✓
├─ Item 1 ├─ Item 1 ✓
├─ Item 2 ├─ Item 2 ✓
└─ ... ├─ Item 3 ← interrupted here
└─ ...
T=5ms: high-priority update arrives!
root.current (stable) workInProgress (ABANDONED)
<App> <App> ← garbage collected
└─ <List> └─ <List>
├─ Item 0 ├─ Item 0
├─ Item 1 ├─ Item 1
├─ Item 2 ├─ Item 2
└─ ... └─ ...
Create NEW workInProgress for the high-priority update:
root.current (stable) NEW workInProgress
<App> <App> (fresh start)
└─ <List> └─ <Input> (high-priority change)
├─ Item 0
└─ ...
User NEVER saw partially-updated Items 0-3 from the abandoned render.
root.current remained stable throughout the interruption.
3. Efficient comparisons
Having two stable trees side-by-side makes prop/state diffing trivial — current holds the old values, WIP holds the new ones:
The real code
function beginWork(current, workInProgress, renderLanes) {
if (current !== null) {
const oldProps = current.memoizedProps; // stable, unchanged
const newProps = workInProgress.pendingProps; // incoming, being computed
if (oldProps === newProps) {
// props didn't change → maybe bailout (skip re-render)
}
}
}
4. Memory reuse — no allocation after the first two renders
The real code
// First render: allocate fibers for workInProgress tree
// Commit: swap — workInProgress becomes current
// Second render: REUSE old current as workInProgress (no allocation!)
// Commit: swap back
// Third render: REUSE again...
// No new fiber allocation after render #1 and #2!
// Just pointer swaps and field resets → memory-efficient
So the second tree costs you one extra copy of the fiber objects — and nothing after that. The overhead is negligible next to the images and JavaScript a page already ships:
Current tree: 1 000 fibers × ~200 bytes = ~200 KB WIP tree: 1 000 fibers × ~200 bytes = ~200 KB Total: ~400 KB For comparison: One image: ~500 KB – 5 MB bundle.js: ~1 MB – 10 MB Fiber memory: ~400 KB ← negligible