Virtual DOM and Reconciliation
What the virtual DOM really is, how React diffs two trees in linear time, why keys decide which state survives, and how Fiber and signals change the picture.
The virtual DOM is a tree of plain JavaScript objects describing what the UI should look like. On every state change React builds a new tree, compares it with the previous one (reconciliation), and writes only the differences to the real DOM (Document Object Model). Its value is not raw speed but letting you describe the whole screen declaratively while the DOM is touched minimally; and the rules the diff uses, element type, position and key, decide which component state survives an update.
Context
Before 2013, front-end code updated the page by hand: find a node, change its text, toggle a class, remember to undo it later. Every piece of state had to be mirrored into the DOM manually, and the two drifted apart. Facebook's React (open-sourced at JSConf US in May 2013) proposed the opposite: describe the entire UI as a function of state, "re-render everything" on each change like a server template, and let the library work out the minimal DOM edits. The trick that made that affordable was rendering into cheap objects first. The idea spread fast: the standalone virtual-dom library (2014), Preact and Inferno, and Vue 2 (2016) all adopted it. Svelte (2016) and later Solid took the opposite path and dropped the virtual DOM entirely.
You have seen it every time you wrote JSX. It is not HTML: the compiler turns it into function calls that return ordinary objects, and that object tree is the virtual DOM.
const el = <li className="todo">Buy milk</li>
// compiles (React 17+ JSX transform) to roughly:
const el2 = jsx('li', {className: 'todo', children: 'Buy milk'})
// and that call just returns a description:
// { type: 'li', key: null,
// props: { className: 'todo', children: 'Buy milk' } }- Element
- The plain object a JSX expression produces: type, props, key. Cheap to create, never mutated, thrown away after the next render.
- Render
- Calling your components to get a new element tree. In React this produces no DOM changes by itself.
- Reconciliation
- Comparing the new tree with the previous one to work out which components keep their state and which DOM nodes must change.
- Commit
- Applying the computed changes to the real DOM, then running layout effects and, later, effects.
- Fiber
- React’s internal record for one component instance (since React 16): its type, state, props and links to parent, child and sibling.
- Key
- A string you give list items (or any element) so React can match old and new children by identity instead of by position.
Why it matters
Most confusing React bugs are reconciliation bugs: typed text appearing in the wrong row after a list reorders, a form that refuses to reset when you switch users, an input losing focus on every keystroke, an effect that re-runs forever because a child remounts. All of them follow from three rules about type, position and key. The performance side matters too: re-rendering is cheap per element but not free, and knowing what a render actually does is the basis for memoization, concurrent rendering and the signal-based frameworks that skip the diff altogether.
How the diff works
The general problem of turning one tree into another with the fewest edits is O(n³); for a thousand elements that is a billion comparisons. React instead uses two assumptions that hold for real UIs and make the diff O(n), one pass over the tree:
- 1Different type at the same position means a different thing. If a
divbecomes asection, or<Profile>becomes<Settings>, React destroys the old subtree, including all component state and DOM nodes, and mounts the new one from scratch. It never tries to morph one into the other. - 2Same type at the same position means the same thing, updated. React keeps the DOM node (or component instance and its state) and only patches the props that changed, then recurses into the children.
- 3Children are matched by key, falling back to position. Without keys, the first old child is compared with the first new child, the second with the second, and so on. With keys, children are matched by identity wherever they moved.
Keys decide which state survives
State in React belongs to a position in the tree, not to your data. A key changes what "position" means for list items: the item with key "42" keeps its state wherever it moves. Using the array index as the key is the same as no key at all, and it breaks as soon as the list is reordered, filtered or prepended. Worked example: a todo list with an uncontrolled input per row.
- 1The list renders two rows, "Buy milk" (key 0) and "Walk dog" (key 1). The user edits the first input to "Buy milk and eggs". That text lives in the DOM input, not in React state.
- 2A new todo, "Call mom", is prepended. The new list is "Call mom" (key 0), "Buy milk" (key 1), "Walk dog" (key 2).
- 3React matches key 0 with key 0: same type, so it keeps the existing input node and only updates
defaultValue, which has no effect after mount. The row now labelled "Call mom" still shows "Buy milk and eggs". - 4Key 1 is reused for "Buy milk" with the original text, and key 2 is new, so React mounts a fresh row for "Walk dog". Every row past the insertion point is wrong or rebuilt.
- 5With
key={todo.id}, React sees one new key at the top, mounts one row and inserts it; the edited input moves with its todo.
{todos.map((todo, i) => (
// BUG: state follows the position, not the todo
<li key={i}>
<input defaultValue={todo.text} />
</li>
))}{todos.map(todo => (
// stable identity from the data
<li key={todo.id}>
<input defaultValue={todo.text} />
</li>
))}Keys as a reset button
Because a changed key means "a different component", it is the cleanest way to throw away state on purpose. Switching the profile below from one user to another unmounts the old form and mounts a fresh one, with no effect that copies props into state. The flip side: a key that changes on every render, such as Math.random(), remounts the subtree every time, losing focus, input and scroll position and re-running every effect.
// a new userId = a new form; old draft state is discarded
<ProfileForm key={userId} userId={userId} />
// never: a fresh key each render remounts on every keystroke
<Row key={Math.random()} item={item} />Render, commit and Fiber
React splits an update into two phases. In the render phase it calls components and reconciles; nothing is visible yet, so this work can be paused, restarted or thrown away, and components must be pure (Strict Mode calls them twice in development to catch impurity). In the commit phase it applies all DOM changes at once, synchronously, so the user never sees a half-updated screen.
The interruptible render phase is what the Fiber rewrite (React 16, 2017) bought. The old reconciler walked the tree recursively and could not stop, so a big update blocked the main thread and input lagged. Fiber turns each component into a unit of work in a linked list that React can process a few milliseconds at a time, yielding to the browser between units and prioritising urgent updates. React 18 (March 2022) exposed this as concurrent rendering: updates wrapped in startTransition are low priority and can be interrupted by typing, and state updates are batched automatically everywhere, including inside promises and timeouts, not just in event handlers.
function Search({all}: {all: Item[]}) {
const [query, setQuery] = useState('')
const [results, setResults] = useState(all)
const [isPending, startTransition] = useTransition()
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value) // urgent: the input stays responsive
startTransition(() => {
// low priority: React may abandon this render if you keep typing
setResults(filterItems(all, e.target.value))
})
}
return (
<>
<input value={query} onChange={onChange} />
<ResultList items={results} dimmed={isPending} />
</>
)
}Frameworks without a diff
Diffing exists because React does not know which parts of the output depend on which state, so it re-runs components and compares. Other frameworks learn that dependency up front and update the affected DOM nodes directly.
| Approach | How an update reaches the DOM | Cost per update | Examples |
|---|---|---|---|
| VDOM diff | Re-run the component, diff old and new subtree, patch | Proportional to the re-rendered subtree | React, Preact |
| Compiler-aware VDOM | Diff, but the compiler marks static nodes and which props can change | Only dynamic nodes are compared | Vue 3 (static hoisting, patch flags) |
| Fine-grained signals | Each signal knows the DOM nodes that read it and updates them | Proportional to what changed; components run once | Solid, Svelte 5 runes, Angular signals |
| Compile-time updates | The compiler emits code that updates specific nodes | No diff at runtime | Svelte 3-4 |
Pitfalls
- Index as key in a list that changes order
Inserting, deleting, sorting or filtering shifts every index, so state, DOM input values and animations stay with the position and end up on the wrong item. Index keys are only safe for static lists that never reorder; anything else needs an ID from the data.
- Unstable keys
Math.random(),crypto.randomUUID()or a key built from mutable fields changes between renders, so React treats each row as brand new: it unmounts and remounts it, inputs lose focus and typed text, and effects run again. Generate IDs once, when the data is created. - Declaring a component inside another component
A function defined in the parent's body is a new function on every render, and a new function is a different component type. React therefore remounts that child each time the parent renders, resetting its state and focus. Define components at module level and pass data through props.
- Expecting state to reset when only props change
Two
<Form>elements in the same spot of a ternary are the same type at the same position, so they share state even if they represent different records. Give them different keys when they should be independent, instead of syncing props into state with an effect. - Assuming the virtual DOM makes every update cheap
The diff limits DOM writes, not JavaScript work: re-rendering a 5,000-row table on each keystroke still calls 5,000 components. Measure with the React Profiler, then virtualise long lists, move state down, use transitions or memoize.
Interview questions
Q1What is the virtual DOM, and is it faster than the real DOM?
It is a tree of plain objects describing the UI that a library like React builds on every render and compares with the previous tree to compute minimal DOM changes. It is not faster than a precise manual DOM update, since the diff is extra work. Its advantage is that you write declarative UI and still get small, batched DOM writes, which is fast enough in practice and far easier to keep correct.
Q2How does React decide what to update without an O(n³) tree diff?
It uses heuristics that make the diff linear. An element of a different type at the same position replaces the whole subtree; the same type keeps the node or instance and patches changed props; and children are matched by key, or by position when there are no keys. That trades optimal edits for speed, which works because real UIs rarely move subtrees between different parents.
Q3What happens when you prepend an item to a list keyed by index?
Every existing item gets a new index, so React matches the new first item with the old first component and reuses its state and DOM nodes, and so on down the list, mounting a new component only for the last index. Visible text may update from props, but component state, uncontrolled input values and focus stay with the old positions, so they appear on the wrong rows. A stable ID key makes it a single insertion.
Q4Walk me through implementing keyed child reconciliation in a tiny virtual DOM library.
Build a map from key to old child. Walk the new children in order: if a key exists in the map, patch that old node with the new props, recurse into its children and move its DOM node into the current position if needed, then delete it from the map; if not, create and insert a new node. Whatever remains in the map was removed, so unmount it. Libraries then minimise moves, for example Vue and Inferno keep the longest increasing subsequence of old positions in place and move only the rest.
Q5What is the difference between the render and commit phases?
Render calls components and reconciles to work out changes; it touches nothing visible, so React can pause, restart or discard it, which is why components must be pure. Commit applies the DOM changes in one synchronous batch, then runs layout effects, and effects after paint. Side effects belong in effects or event handlers, never in render.
Q6What problem did React Fiber solve?
The original reconciler recursed through the whole tree in one go, so a large update blocked the main thread and made input lag. Fiber, shipped in React 16 in 2017, represents each component as a unit of work that can be paused and resumed, which lets React yield to the browser and prioritise urgent updates. That is the foundation for concurrent features like transitions and Suspense in React 18.
Q7When would you use startTransition?
When an update triggers expensive rendering but is not urgent, such as filtering a large list or switching tabs, while something else, like typing, must stay instant. The urgent update stays outside the transition, the expensive one goes inside, and React can interrupt the transition render if new input arrives. It does not make rendering cheaper, it changes its priority.
Q8How do Svelte and Solid update the DOM without a virtual DOM?
They track which DOM nodes depend on which piece of state ahead of time. Svelte 3-4 compiled components into code that updates specific nodes when specific variables change; Solid and Svelte 5 use signals, where reading a signal subscribes the reading DOM binding and writing it updates exactly those bindings. Components run once, so there is nothing to diff, and the cost of an update is proportional to what changed rather than to the subtree size.
- The virtual DOM is a cheap object tree rebuilt on each render; reconciliation diffs it against the last one and commits minimal DOM changes.
- Its benefit is declarative code with batched, minimal DOM writes, not being faster than hand-written DOM updates.
- Diffing is linear thanks to heuristics: new type replaces the subtree, same type patches props, children match by key or position.
- State belongs to a position in the tree. Stable keys from your data keep it with the right item; index or random keys move or destroy it; a changed key resets it on purpose.
- Render is pure and interruptible (Fiber, React 16); commit is synchronous. React 18 added automatic batching and transitions on top.
- Signals and compilers (Solid, Svelte, Vue's optimisations) know dependencies up front and skip most or all of the diff.