Skip to content

Reconciliation Algorithm

advanced20 min read

The Diffing Problem

Here's a number that should surprise you: comparing two arbitrary trees is O(n³) in the general case. For a tree with 1,000 nodes, that is one billion operations. React needs to diff trees 60 times per second. The general algorithm is completely unusable.

So React cheats — brilliantly. It solves this with two heuristics that reduce the problem to O(n):

  1. Elements of different types produce different trees — if a <div> becomes a <span>, React does not attempt to patch it. It unmounts the entire old subtree and mounts a new one.
  2. The key prop hints which children are stable across renders — React uses keys to match children in the old tree to children in the new tree without scanning the entire list.

These heuristics are not guaranteed to produce the minimum number of DOM mutations. They are designed to produce correct results fast in the 99.9% case.

Mental Model

Think of reconciliation as a document editor's "track changes" feature. React reads through the old and new versions of your UI side by side, marking what was added, removed, or changed. But instead of a character-by-character diff (which would be O(n²)), it works at the element level — comparing type and key to decide whether something is "the same thing, updated" or "a completely different thing." The key insight: it only compares nodes at the same position in the tree, never across different levels.

Same Type: Update

This is the happy path. When the old and new elements have the same type, React keeps the DOM node and updates only the changed attributes.

// Old
<div className="before" title="stuff" />

// New
<div className="after" title="stuff" />

// React's action: keep the DOM node, update className from "before" to "after"
// title is unchanged — no DOM operation needed

For components, same type means React keeps the instance (class) or the fiber (function). It calls the component again with new props, and the reconciliation continues recursively into the component's children.

// Old
<UserCard name="Alice" role="engineer" />

// New
<UserCard name="Alice" role="senior engineer" />

// React's action: keep the UserCard fiber, call it with new props
// The component re-renders with role="senior engineer"
// React then reconciles UserCard's returned JSX against the previous output
Quiz
A parent re-renders and its child changes from <UserCard name='Alice' /> to <UserCard name='Bob' />. What does React do?

Different Type: Unmount and Mount

And here's where React gets ruthless. When the element type changes, React tears down the entire old subtree — unmounting all components, destroying state, removing DOM nodes — and builds the new subtree from scratch.

// Old
<div>
  <Counter />    {/* Has internal state: count = 5 */}
  <p>Hello</p>
</div>

// New
<section>
  <Counter />    {/* Fresh mount: count = 0 */}
  <p>Hello</p>
</section>

// React's action:
// 1. Unmount entire <div> subtree (Counter loses state, <p> removed from DOM)
// 2. Mount entire <section> subtree (new Counter with count=0, new <p>)

This is aggressive but correct. React assumes that structurally different containers produce structurally different content. In practice, this heuristic is right almost always.

Common Trap

Wrapping a component in a different container destroys all state below it — including deeply nested children. This is one of the most common sources of "my state keeps resetting" bugs:

// BUG: toggling isSpecial unmounts and remounts the entire form
function Page({ isSpecial }) {
  if (isSpecial) {
    return <div className="special"><Form /></div>;
  }
  return <section className="normal"><Form /></section>;
}
// Fix: use the same container type, change only the className
function Page({ isSpecial }) {
  return (
    <div className={isSpecial ? 'special' : 'normal'}>
      <Form />
    </div>
  );
}
Quiz
A component conditionally renders either <div>`<Input /></div>` or `<span><Input />`</span>. The user types text into the Input, then the condition flips. What happens to the typed text?

List Reconciliation: Why Keys Matter

This is the part that burns people in production more than almost anything else. When reconciling a list of children, React needs to figure out which items are new, which were removed, and which just moved. Without keys, React can only compare children by position.

Without Keys (Index-Based)

// Old
<ul>
  <li>Alice</li>    {/* index 0 */}
  <li>Bob</li>      {/* index 1 */}
</ul>

// New (Charlie prepended)
<ul>
  <li>Charlie</li>  {/* index 0 — React thinks this is "Alice updated to Charlie" */}
  <li>Alice</li>    {/* index 1 — React thinks this is "Bob updated to Alice" */}
  <li>Bob</li>      {/* index 2 — React creates a new node */}
</ul>

// React's action without keys:
// 1. Update text of li[0] from "Alice" to "Charlie"
// 2. Update text of li[1] from "Bob" to "Alice"
// 3. Insert new li[2] with "Bob"
// Three DOM mutations. All components at index 0 and 1 re-render with wrong props.

Every child "shifts" — React updates every single item. And here's the really nasty part: if these list items have state (checkboxes, form inputs, animations), that state gets attached to the wrong items.

With Keys

// Old
<ul>
  <li key="alice">Alice</li>
  <li key="bob">Bob</li>
</ul>

// New
<ul>
  <li key="charlie">Charlie</li>
  <li key="alice">Alice</li>
  <li key="bob">Bob</li>
</ul>

// React's action with keys:
// 1. "charlie" is new — insert new li
// 2. "alice" — same key, same type, same props — no update needed
// 3. "bob" — same key, same type, same props — no update needed
// One DOM insertion. Existing components keep their state.
Quiz
You render a list of 1000 items using array index as the key. A new item is inserted at the beginning. How many DOM mutations does React perform?

The Key Reconciliation Algorithm

React's key-based reconciliation uses a two-pass approach for lists:

Pass 1: Linear scan from the start. Compare old children and new children position by position. As long as keys match, update in place. Stop at the first mismatch.

Pass 2: Map-based matching. Build a map of key → old fiber for remaining old children. Walk through remaining new children, looking up each key in the map. If found: reuse the fiber (move). If not found: create new.

// Simplified from ReactChildFiber.js — reconcileChildrenArray
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren) {
  let oldFiber = currentFirstChild;
  let newIdx = 0;

  // PASS 1: Match from the beginning
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.key !== newChildren[newIdx].key) {
      break; // Keys diverge — switch to map-based
    }
    // Same key: update this fiber with new props
    updateSlot(returnFiber, oldFiber, newChildren[newIdx]);
    oldFiber = oldFiber.sibling;
  }

  // PASS 2: Build map, match remaining
  const existingChildren = mapRemainingChildren(oldFiber);
  for (; newIdx < newChildren.length; newIdx++) {
    const matchedFiber = existingChildren.get(newChildren[newIdx].key);
    if (matchedFiber) {
      // Reuse existing fiber (move operation)
      existingChildren.delete(newChildren[newIdx].key);
    } else {
      // Create new fiber (insertion)
    }
  }

  // Delete remaining old fibers not matched by any new child
  existingChildren.forEach(child => deleteChild(returnFiber, child));
}
Why React doesn't use LIS (Longest Increasing Subsequence)

Vue 3 and Inferno use the Longest Increasing Subsequence algorithm to find the optimal set of moves for list reordering. React intentionally does not — it processes children left to right and marks nodes as "moved" if they appear out of order relative to a lastPlacedIndex. This is simpler and handles the common case (append, prepend, few moves) well. The tradeoff is that React may perform more DOM moves than theoretically necessary for adversarial reordering (e.g., reversing a list). In practice, the difference is negligible because full list reversals are rare, and DOM move cost is dominated by browser layout, not the number of moves.

Render Phase vs Commit Phase

Now let's talk about the two-phase architecture — and understanding the boundary between them is critical for everything from debugging to performance optimization.

Render Phase (Pure, Interruptible)

The render phase walks the fiber tree, calling components, diffing output, and building a list of changes needed. It produces side effects but does not execute them.

  • Calls component functions / class render() methods
  • Runs hooks (useState, useMemo, etc.)
  • Diffs old vs new fiber children
  • Tags fibers with effect flags: Placement, Update, Deletion
  • No DOM mutations, no lifecycle side effects
  • Can be interrupted, restarted, or abandoned (in concurrent mode)

Commit Phase (Effectful, Synchronous)

The commit phase takes the completed fiber tree and applies all effects to the DOM in one synchronous batch.

  1. Before mutation: Read DOM measurements (getSnapshotBeforeUpdate)
  2. Mutation: Apply all DOM changes (insertions, updates, deletions)
  3. Layout: Run useLayoutEffect callbacks, componentDidMount/componentDidUpdate
  4. After the browser paints: Run useEffect callbacks
Render Phase                          Commit Phase
─────────────                         ────────────
beginWork(App)                        ┌─ Before Mutation
beginWork(Header)                     │   getSnapshotBeforeUpdate
completeWork(Header)                  │
beginWork(Main)          ──commit──→  ├─ Mutation
beginWork(Content)                    │   DOM insertions/updates/deletions
completeWork(Content)                 │
completeWork(Main)                    ├─ Layout
completeWork(App)                     │   useLayoutEffect callbacks
                                      │
[Interruptible]                       └─ [Synchronous, uninterruptible]
                                      
                                      Browser paints
                                      ↓
                                      useEffect callbacks (async)
Quiz
Why must the commit phase be synchronous while the render phase can be interrupted?

Effect Flags and the Subtree Flags Optimization

Here's a clever optimization you'll appreciate. React tags each fiber with flags indicating what effects it needs:

const Placement    = 0b0000000000000010;  // New node — needs DOM insertion
const Update       = 0b0000000000000100;  // Changed — needs DOM attribute update
const Deletion     = 0b0000000000001000;  // Removed — needs DOM removal
const ChildDeletion = 0b0000000000010000; // A child was deleted

In older React, the commit phase walked the entire fiber tree looking for fibers with flags. With the subtreeFlags optimization (React 18+), each fiber aggregates its children's flags upward during completeWork:

// During completeWork, bubble child flags up
fiber.subtreeFlags |= child.flags | child.subtreeFlags;

Now during the commit phase, React can skip entire subtrees that have no effects:

if (fiber.subtreeFlags === NoFlags && fiber.flags === NoFlags) {
  // Skip this entire subtree — nothing changed
  return;
}

For a tree of 10,000 fibers where only 5 changed, the commit phase visits ~20 fibers instead of 10,000.

Quiz
A tree has 5000 fibers. Only one leaf fiber deep in the tree has a Placement flag. How does the commit phase find it efficiently?

Reconciliation Rules Summary

Key Rules
  1. 1Same type at same position: React reuses the fiber/DOM node and updates changed props. State is preserved.
  2. 2Different type at same position: React unmounts the entire old subtree (destroying all state) and mounts a new one.
  3. 3React only compares nodes at the same tree level. It never moves a component to a different depth.
  4. 4Keys identify which children are 'the same item' across renders. Without keys, React matches by position index.
  5. 5Array index as key causes every item to re-render on insertions/deletions. Use stable, unique identifiers.
  6. 6The render phase is pure and interruptible — it builds a plan of changes. The commit phase is synchronous — it applies all DOM mutations atomically.
  7. 7subtreeFlags optimization lets React skip entire unchanged subtrees during the commit phase.
Interview Question

Q: Why does React use O(n) heuristic diffing instead of optimal O(n³) tree diff? What are the tradeoffs?

A strong answer covers: the two heuristics (same type = update, different type = replace; key-based child matching), why they work in practice (component types rarely change at a given position, keys are stable), the specific case where they are suboptimal (moving a subtree to a different depth forces unmount/remount), the two-pass list reconciliation (linear scan then map lookup), and why React chose simplicity + speed over optimality — the extra DOM mutations from suboptimal diffing are far cheaper than the cost of computing the optimal diff.