Frontend
Virtual DOM & React Reconciliation
Why React diffs an in-memory tree instead of touching the real DOM directly, and how keys and Fiber make that diff fast, correct, and interruptible.
Every time state changes in a React app, something has to figure out what the screen should look like now — and, more importantly, what actually changed since the last render. React's answer is the virtual DOM: a plain JavaScript tree of objects describing the UI, rebuilt on every render, and compared against the previous tree before a single real DOM node is touched. That comparison step is reconciliation, and it's the reason React feels declarative — you describe the end state, and React works out the edits.
Why not just mutate the real DOM directly?
The real DOM is expensive to touch. Every attribute change, every inserted node, every class toggle can trigger layout recalculation and repaint — work the browser does to figure out where things sit on the page and how they should look. Do that once and it's cheap; do it in a tight loop across dozens of unrelated updates and it adds up fast, especially when updates trigger layout thrashing by interleaving reads and writes.
A virtual DOM node, by contrast, is just an object literal — no layout, no paint, no browser involved at all. React can build an entire new tree of these objects on every render for almost nothing, then compare it against the previous tree to compute the minimal set of real DOM operations required. Instead of "re-render everything," the browser only ever sees "change this one attribute" or "insert this one node." The virtual tree is the planning layer; the real DOM is where the plan gets executed, once, in a batch.
The diffing heuristics
Diffing two arbitrary trees for a minimal edit script is an expensive problem in general — exponential in the size of the tree if you solve it exactly. React sidesteps that cost with a few heuristics that hold up well for real UIs, even though they're not theoretically optimal:
- Same type, same position → update in place. If the old and new tree have a
<div>in the same slot, React keeps the underlying DOM node and just diffs its attributes and children, rather than tearing anything down. - Different type → tear down and rebuild. If a
<div>is replaced by a<span>(or a component swaps for a different component), React doesn't try to reconcile the two — it discards the entire old subtree, including any component state and DOM nodes underneath it, and mounts the new one from scratch. - Lists are compared by key, not by position. Children of the same parent are matched up using each element's
keyprop rather than its index in the array, so React can tell "this is the same logical item, just moved" apart from "this is a new item."
Why keys matter more than they look
The key rule is easy to skim past and expensive to get wrong. When you don't provide one, React falls back to matching children by their position in the array. That works fine as long as the list never reorders, inserts in the middle, or deletes anything — but the moment it does, position and identity drift apart, and React ends up reusing the wrong DOM node for the wrong piece of data.
The classic symptom is an uncontrolled <input> inside a list. DOM nodes (and the local state living inside them) are attached to a slot, not to the item you think occupies it. Delete the first row, and React sees "slot 0 is still aRow, slot 1 is still a Row" — so it keeps every existing DOM node in place and just updates their props, shifting each row's data up by one while its DOM identity (and any text a user had already typed into an uncontrolled input) stays put. The result: the input that used to belong to row 2 now displays row 1's label, but still holds whatever text the user typed while it was row 2's input.
// Before: rows keyed by array index
{items.map((item, index) => (
<Row key={index}>
<input defaultValue={item.name} />
</Row>
))}
// Reorder or delete the first item, and React matches
// old index 1 to new index 0 — the <input> at position 0
// keeps ITS OLD uncommitted text, now attached to the
// wrong row's data.
// After: rows keyed by a stable identity
{items.map((item) => (
<Row key={item.id}>
<input defaultValue={item.name} />
</Row>
))}
// React now tracks each row by item.id, so a reorder moves
// the existing DOM node (and its input state) along with it
// instead of reusing it for a different row.A stable, content-derived key — a database id, a UUID, anything that uniquely and durably identifies the item — fixes this completely. With key={item.id}, React matches old elements to new ones by identity first, so a reorder moves the existing node (and everything attached to it) along with its data, instead of quietly reusing it for a neighbor.
Index-as-key is a code smell, not a rule
Using the array index as key is fine only when the list is static and never reorders, filters, or has items inserted or removed from the middle. The moment any of that becomes possible, index keys reintroduce exactly the bug a real key was supposed to prevent — so it's safest to reach for a stable identifier by default and treat the index as the exception, not the norm.
Fiber: making reconciliation interruptible
The diffing model above describes what React computes; it doesn't say anything about scheduling that work over time. The original reconciler walked the tree recursively and synchronously — once it started, it couldn't stop until the whole tree was processed, which meant a large update could block the main thread long enough to make typing or scrolling feel janky.
Fiber is React's reconciler rewrite that restructures this walk as an explicit, resumable data structure instead of a plain recursive call stack. Each "fiber" is a unit of work — roughly one component instance — linked to its parent, sibling, and child fibers, so React can pause after any unit, hand control back to the browser to handle something more urgent (a keystroke, an animation frame), and pick up exactly where it left off. Fiber also attaches a priority to updates: a state update from a direct user interaction can preempt a lower priority background render, which is the mechanism underneath features like startTransition and useDeferredValue.
'Virtual DOM is always faster' is a myth
It's common to hear the virtual DOM described as inherently faster than direct DOM manipulation. It isn't — a hand-tuned imperative update that changes exactly the three attributes that need changing will always beat rebuilding a tree of objects, diffing it, and then applying the same three changes. The virtual DOM's actual win is different: it gives you a good default performance/ergonomics trade-off at scale. You write declarative, easy-to-reason-about code for every component, and the diffing algorithm — not you — figures out the minimal real DOM update for the whole tree, every time, without hand optimization anywhere.
What this means day to day
None of this needs to be reasoned about explicitly on every render — that's the point of the abstraction. But when a list starts behaving strangely after a filter or sort, or a component's local state seems to "leak" into the wrong row, the fix is almost always the same: check that every element type stays consistent across renders where you expect state to persist, and that every list item carries a key tied to its actual identity, not its position.
Key takeaways
- The virtual DOM is a cheap in-memory tree React diffs before touching the real DOM, so only the minimal set of real DOM edits ever gets applied.
- React compares same-position elements by type: same type updates in place, a different type tears down and rebuilds the whole subtree.
- Lists are matched by key, not index — without a stable key, a reorder or deletion can silently attach the wrong data to an existing DOM node's state.
- Prefer a stable, content-derived key (an id) over the array index for any list that can reorder, filter, or have items removed from the middle.
- Fiber rewrote reconciliation as interruptible, priority-aware units of work, so urgent updates like a keystroke can preempt a lower-priority render.
- The virtual DOM isn't universally faster than direct DOM manipulation — its win is a strong default trade-off between performance and developer ergonomics, not raw speed.
