Diffing, Step by Step
What you'll learn
- The four rules React uses to decide "keep, update, or destroy"
- Why changing an element's type throws away its whole subtree, state included
- How same-type updates reuse the node and patch only what changed
- How React diffs lists with keys, and why its algorithm is "good enough" on purpose
Here is the moment everything so far was building toward. React has the current fiber tree (what is) and a fresh element tree (what should be). Now it must decide, node by node: keep this fiber and patch it, or destroy it and build a new one? That decision procedure is called reconciliation, and it's astonishingly small: four rules. Once you can recite them, you can predict React's behavior in situations that baffle developers with years of experience.
Jargon: "reconciliation". The process of diffing the new element tree against the current fiber tree to compute which fibers to keep, which to update, and which to destroy. Its output is a set of flagged changes for the commit phase.
The four rules
R1, Different type at a position → destroy and rebuild. If the element at a position has a different type than before (div became span, Counter became Timer), React throws away that fiber and its entire subtree, every descendant, every piece of hook state, every DOM node, and builds a fresh subtree.
R2, Same type at the same position → keep and patch. The fiber (and its state, and its DOM node) survives. React diffs old props against new props and records only the differences.
R3, Children are compared in order. First old child with first new child, second with second. No searching, no cleverness, position is identity.
R4, Keyed children are matched by key across positions. In lists, a key overrides R3: React pairs old and new children that share a key, wherever they ended up.
R1 and R2 are about one position. R3 and R4 are about lists of children. Let's watch each rule fire.
Walkthrough 1: a type change destroys everything (R1)
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
export default function App() {
const [wrapped, setWrapped] = useState(true);
return (
<div>
<label>
<input
type="checkbox"
checked={wrapped}
onChange={() => setWrapped(!wrapped)}
/>
Wrap in a div (uncheck for span)
</label>
{wrapped ? (
<div className="box"><Counter /></div>
) : (
<span className="box"><Counter /></span>
)}
</div>
);
}
Click the counter a few times, say it shows 5. Now toggle the wrapper.
What happens:
- New element at the wrapper position: type
span. Current fiber at that position: typediv. - R1 fires:
div≠span. React schedules the entiredivsubtree for destruction, including theCounterfiber and its hook cell holding5. - A fresh
spansubtree is built: newspanfiber, newCounterfiber, new hook cell initialized to0, new DOM nodes. - Commit: the old DOM subtree is removed, the new one inserted.
The count resets to 0. Not because React "reset" anything, the old Counter literally ceased to exist, and the button on screen is a different Counter that happens to run the same function. The same component type at a different position in the tree is a different component. Position plus type is identity (until keys enter the picture).
Notice the checkbox state does survive: its fiber sits above the destroyed subtree, untouched.
Walkthrough 2: same type keeps the node (R2)
import { useState } from 'react';
export default function App() {
const [clicks, setClicks] = useState(0);
const hot = clicks >= 3;
return (
<button
className={hot ? 'btn hot' : 'btn'}
onClick={() => setClicks(clicks + 1)}
>
Clicks: {clicks}
</button>
);
}
Click three times.
What happens on the third click:
- New element at the button's position: type
button. Current fiber: typebutton. - R2 fires: same type, same position → keep. The fiber, its DOM node, and the hook cell (now
3) all survive. - Props are diffed field by field.
classNamechanged'btn'→'btn hot'→ flag: update class.onClickis a fresh arrow function every render, but swapping a listener is cheap bookkeeping, not a DOM rebuild. Children diff: text'Clicks: 2'→'Clicks: 3'→ flag: update text. - Commit: exactly two small writes. No node recreated, no state lost.
R2 is the rule that makes React feel efficient: steady-state updates, the 99% case, keep every node and patch only what moved.
Walkthrough 3: the list algorithm (R3 + R4)
Now the interesting one. A keyed list reorders from [A B C D] to [D A B C]:
import { useState } from 'react';
const initial = [
{ id: 'A', label: 'Apples' },
{ id: 'B', label: 'Bread' },
{ id: 'C', label: 'Cheese' },
{ id: 'D', label: 'Dates' },
];
export default function App() {
const [items, setItems] = useState(initial);
return (
<div>
<button onClick={() => setItems([items[3], ...items.slice(0, 3)])}>
Move last to front
</button>
<ul>
{items.map((item) => (
<li key={item.id}>{item.label}</li>
))}
</ul>
</div>
);
}
React diffs the <ul>'s children in two passes:
Pass 1, the linear walk. React walks old and new children side by side, updating in place, as long as keys match positionally:
- Old position 0: key
A. New position 0: keyD. Mismatch → Pass 1 stops immediately.
Pass 1 handles the common cases, appends, in-place edits, in one cheap sweep. Here it bails out at the very first position.
Pass 2, the map-based pass. React takes the remaining old children (all four) and builds a lookup map: A→0, B→1, C→2, D→3 (key → old index). Then it walks the new list, finds each child's old fiber by key, and decides: reuse in place, or reuse and move?
The decision uses one number: lastPlacedIndex, the old index of the last node we decided not to move. The heuristic:
Pseudocode model, not real source:
let lastPlacedIndex = 0;for (const newChild of newChildren) {const oldIndex = oldIndexByKey.get(newChild.key);if (oldIndex === undefined) {markInsert(newChild); // key never existed: fresh node} else if (oldIndex < lastPlacedIndex) {markMove(newChild); // it must leapfrog a kept node} else {lastPlacedIndex = oldIndex; // stays put; raise the watermark}}// any old fibers never matched get marked for deletion
Trace it for the new list [D A B C]:
- D, old index 3.
3 < 0? No → don't move.lastPlacedIndex = 3. - A, old index 0.
0 < 3? Yes → move. - B, old index 1.
1 < 3? Yes → move. - C, old index 2.
2 < 3? Yes → move.
So D stays put, and A, B, C are re-inserted after it. The intuition: once a node from far right is kept early (D), everything originally to its left must shuffle past it, the watermark at 3 condemns all of them. "Everything after a moved-left item gets marked to move."
Result: 3 moves. The theoretically minimal edit is 1 move (pick up D, drop it in front). React takes the 3-move answer, on purpose.
Why React doesn't do minimal diffing
A perfect tree-diff algorithm exists in the literature, and the best known general versions run around O(n³). Even for flat lists, computing the truly minimal set of moves costs extra passes and bookkeeping. React's bets:
- O(n) with good-enough moves beats O(n³) with perfect moves. Diffing runs on every update; full reorders are rare; appends and edits are constant. Optimize the common path.
- DOM moves are rare in real UIs. Most list updates are appends, edits, or removals, Pass 1 plus map lookups handle those with zero wasted moves.
- Extra moves are cheap compared to wrong identity. A redundant
insertBeforecosts microseconds; losing a fiber's state (R1-style destruction) costs user-visible bugs. The algorithm spends its cleverness on identity, not on minimal motion.
Worth saying plainly: R1's "different type → destroy" is the same philosophy. Two elements of different types usually produce wildly different subtrees, so React doesn't even look inside, it assumes dissimilarity and rebuilds. A heuristic that saves enormous work and is wrong only in rare, contrived cases.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: React computes the minimal possible set of DOM edits. Reality: it computes a good set in O(n), list reorders may get extra moves, and that's a deliberate trade.
- Misconception: State belongs to the component function, so it survives wherever the function is used. Reality: state belongs to a fiber, identified by position + type (or key). Same function elsewhere = different fiber = fresh state.
- Misconception: Changing a wrapper from
divtospanpreserves the children inside. Reality: R1 destroys the whole subtree; children are rebuilt from scratch, state included. - Misconception: React looks inside both subtrees to check if they're similar before destroying. Reality: it never looks, a different type means immediate teardown.
- Misconception: Keys exist to make lists faster. Reality: keys exist to give children stable identity; speed is a side effect (next chapter).
Why it works this way
- Type changes almost always mean total change.
div→spanorCounter→Timerrarely share meaningful internal structure, so comparing deeply would waste work. Teardown is the cheap correct answer. - Same-type patching covers the hot path. Real apps re-render the same components with slightly different props thousands of times; R2 makes that nearly free.
- O(n) heuristics scale; O(n³) perfection doesn't. A diff that runs on every keystroke must never become the bottleneck.
- Position is the best default identity. For static children, the overwhelming majority, positional matching is exactly right with zero annotation from you.
- Keys are the escape hatch. When position isn't identity, reorderable, filterable, prependable lists, you declare identity explicitly, and R4 honors it.
Try it yourself
- Run Walkthrough 1's app. Count to 5, toggle the wrapper, watch the reset. Then change the ternary so both branches render
<div className="box">(same type), now the count survives, proving R2. - In Walkthrough 2, open DevTools, right-click the button in the Elements panel → Break on → subtree modifications. Click three times: the breakpoint fires only for the text child, never for the button itself, the node is kept.
- Build the list from Walkthrough 3 with a tiny stateful child (
<li key={item.id}>{item.label} <Counter /></li>). Reorder and confirm each Counter's state travels with its key, the visible payoff of R4.
Recap
- Reconciliation = four rules: type change destroys (R1), same type patches (R2), children pair by position (R3), keys override position (R4).
- R1 teardown loses all descendant state, the same component type at a different position is a different component.
- R2 keeps fiber + state + DOM node and patches only changed props, the hot path.
- Lists diff in two passes: a positional walk, then a key-map pass with the
lastPlacedIndexwatermark deciding moves. - The watermark heuristic: anything originally left of a kept-right node gets marked to move, good-enough moves in O(n).
- React chooses good-enough O(n) diffing over perfect O(n³) diffing, deliberately.