
Understanding the Virtual DOM and Reconciliation
"React is fast because of the virtual DOM" is one of the most repeated lines in frontend development, and it's only half true. The virtual DOM isn't a performance trick that makes the DOM faster. It's what lets you write UI as a function of state, re-run that function whenever state changes, and still only touch the parts of the real DOM that actually changed.
Understanding how that works explains a lot of React behavior that otherwise feels random. Why does an input lose its text when you wrap it in a <div>? Why does state survive when you swap one user's profile for another's? Why does defining a component inside another component reset everything on each render? All of these come from the rules React uses to compare one tree with the next, a process called reconciliation.
In this post you'll see what the virtual DOM actually contains, how a render turns into DOM updates, the rules reconciliation follows, how those rules decide whether state is kept or thrown away, and the practical patterns that come out of them. There's also a tiny diffing implementation so you can see the core idea in code.
What the Virtual DOM Actually Is
The "virtual DOM" is just the tree of plain JavaScript objects your components return. JSX compiles to function calls that create these objects, called React elements.
const element = (
<button className="primary" onClick={save}>
Save
</button>
);
With the modern JSX transform, that compiles to roughly:
import { jsx as _jsx } from "react/jsx-runtime";
const element = _jsx("button", {
className: "primary",
onClick: save,
children: "Save",
});
And the result is an object like this (simplified):
{
$$typeof: Symbol.for("react.transitional.element"),
type: "button",
key: null,
props: { className: "primary", onClick: save, children: "Save" },
}
That's it. An element is a cheap, immutable description: "a button with these props." It isn't a DOM node, and creating one doesn't touch the browser. For your own components, type is the function itself instead of a string:
{ type: UserCard, key: null, props: { userId: 42 } }
When React renders UserCard, it calls the function, which returns more elements, and so on until everything is described in terms of host elements like "div" and "button". That full description is what people mean by the virtual DOM.
From Render to Real DOM: Two Phases
Every update in React goes through two phases:
- Render phase. React calls your components to get the new element tree, then compares it with the previous one. The output is a list of changes: insert this node, update that attribute, remove this subtree. Nothing touches the DOM yet. This phase must be pure, which is why React can call components more than once in Strict Mode.
- Commit phase. React applies the list of changes to the real DOM in one go, then runs layout effects, then (a bit later) regular effects.
Reconciliation is the comparison in the render phase. Since React 16, it's implemented by the Fiber architecture, which lets the render phase be split into chunks and interrupted. You can read about that in React Fiber architecture explained simply. For this post, the algorithm's rules matter more than the engine running them.
Why Not Just Re-Render the DOM?
The naive approach to "UI as a function of state" is to throw away the DOM and rebuild it on every change. That's slow, but more importantly it's destructive: rebuilding an <input> loses focus, cursor position, and selection. Rebuilding a <video> restarts playback. Scroll positions reset.
The virtual DOM solves this by comparing descriptions, which is cheap, and only mutating the DOM where they differ. The DOM nodes that didn't change are the same nodes as before, so focus, scroll, and media state survive.
Comparing two arbitrary trees optimally is an expensive problem, roughly O(n³) for n nodes. That's far too slow for UIs with thousands of elements. React uses a set of heuristics that make it O(n) instead, at the cost of sometimes doing more work than strictly needed.
The Rules of Reconciliation
React compares the old and new trees from the top down, one position at a time. At each position, it applies these rules.
Rule 1: Different Type Means a New Subtree
If the element type at a position changes, React doesn't try to match anything underneath. It unmounts the old subtree, including all state, and mounts a fresh one.
// Before
<div>
<Counter />
</div>
// After
<section>
<Counter />
</section>
Even though Counter is in the same spot inside its parent, the parent changed from div to section, so the Counter is destroyed and recreated. Its state resets to the initial value. The same applies when switching between two different components, like <LoginForm /> and <SignupForm />.
Rule 2: Same Host Type Means Update the Attributes
If the type is the same host element, like div to div, React keeps the DOM node and updates only the props that changed:
// Before
<div className="card" title="Old" />
// After
<div className="card card--active" title="Old" />
React changes className and leaves title alone. For style, it updates individual properties that changed. Then it recurses into the children.
Rule 3: Same Component Type Means Re-Render With New Props
If the type is the same component, React keeps the component instance, including its state, hooks, and refs, and calls it again with the new props. Then it reconciles whatever that component returns.
This is why changing a prop doesn't reset a component's state:
<ProfileCard userId={selectedId} />
When selectedId changes from 1 to 2, it's still a ProfileCard at the same position. Any useState inside keeps its value from user 1. That's often a bug, for example a "draft comment" field that still shows text typed for the previous user. The fix is in the next section.
Rule 4: Children Are Matched by Position, Unless They Have Keys
When reconciling a list of children, React by default matches them by index: old first child with new first child, and so on. That works for static children but breaks down for lists that reorder or insert at the start. Keys tell React which old child corresponds to which new child, regardless of position. Keys deserve their own deep dive, which you'll find in why keys matter when rendering lists in React.
Identity: Type Plus Position Plus Key
Put those rules together and you get the central idea: React decides whether a component is "the same" by its type, its position in the tree, and its key. State belongs to that identity, not to the component function or the props.
This explains several common surprises.
Conditional Rendering at the Same Position
function Checkout({ isGift }: { isGift: boolean }) {
return (
<div>
{isGift ? <AddressForm label="Recipient" /> : <AddressForm label="Shipping" />}
</div>
);
}
Both branches render an AddressForm at the same position. Toggling isGift doesn't reset the form, because to React it's the same component with a different label. Whatever the user typed stays. That might be what you want, or it might not.
If you want separate state for each branch, give them different keys:
{isGift ? (
<AddressForm key="recipient" label="Recipient" />
) : (
<AddressForm key="shipping" label="Shipping" />
)}
Now they have different identities, and switching unmounts one and mounts the other.
Resetting State With a Key
The same trick fixes the ProfileCard problem from earlier:
<ProfileCard key={selectedId} userId={selectedId} />
When selectedId changes, the key changes, so React treats it as a new component and resets all its state. This is the recommended way to reset state when an identity changes, and it's much cleaner than an effect that watches the prop and calls a bunch of setters. It's one of the patterns covered in derived state in React: common mistakes and better patterns.
Positions Hold Even When Something Is Hidden
function Panel({ showBanner }: { showBanner: boolean }) {
return (
<div>
{showBanner && <Banner />}
<Editor />
</div>
);
}
You might expect Editor to move from position 0 to position 1 when the banner appears and get remounted. It doesn't. When showBanner is false, the expression evaluates to false, which still occupies the first slot in the children array. Editor is always the second child, so its state survives. This is why cond && <X /> patterns are safe.
Never Define Components Inside Components
This is the most damaging consequence of the identity rules:
function SearchPage() {
const [query, setQuery] = useState("");
// A new function, and therefore a new component type, every render
function SearchBox() {
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
return <SearchBox />;
}
Every time SearchPage renders, SearchBox is a brand new function. React compares types by reference, sees a different type, and remounts the input. The input loses focus after every keystroke. Move component definitions to the top level of the module and pass data through props.
A Tiny Reconciler in Code
To make the rules concrete, here's a toy reconciler for host elements only. It's nowhere near React's real implementation, but it follows the same core rules: replace on type change, patch props on same type, and recurse into children by index.
// toy-reconcile.js
function createDom(vnode) {
if (typeof vnode === "string") return document.createTextNode(vnode);
const el = document.createElement(vnode.type);
setProps(el, {}, vnode.props);
vnode.children.forEach((child) => el.appendChild(createDom(child)));
return el;
}
function setProps(el, oldProps, newProps) {
for (const name of Object.keys(oldProps)) {
if (!(name in newProps)) el.removeAttribute(name);
}
for (const [name, value] of Object.entries(newProps)) {
if (oldProps[name] !== value) el.setAttribute(name, value);
}
}
export function reconcile(parent, oldVnode, newVnode, index = 0) {
const existing = parent.childNodes[index];
if (oldVnode == null) {
parent.appendChild(createDom(newVnode));
} else if (newVnode == null) {
parent.removeChild(existing);
} else if (typeof oldVnode === "string" || typeof newVnode === "string") {
if (oldVnode !== newVnode) parent.replaceChild(createDom(newVnode), existing);
} else if (oldVnode.type !== newVnode.type) {
// Rule 1: different type, replace the whole subtree
parent.replaceChild(createDom(newVnode), existing);
} else {
// Rule 2: same type, patch props and recurse into children
setProps(existing, oldVnode.props, newVnode.props);
const oldLen = oldVnode.children.length;
const newLen = newVnode.children.length;
for (let i = 0; i < newLen; i++) {
reconcile(existing, oldVnode.children[i], newVnode.children[i], i);
}
// Remove leftover old children, last first, so indexes stay valid
for (let i = oldLen - 1; i >= newLen; i--) {
reconcile(existing, oldVnode.children[i], null, i);
}
}
}
Usage:
import { reconcile } from "./toy-reconcile.js";
const root = document.getElementById("root");
const h = (type, props, ...children) => ({ type, props: props ?? {}, children });
let current = h("ul", { class: "list" }, h("li", null, "Apples"), h("li", null, "Pears"));
reconcile(root, null, current);
const next = h("ul", { class: "list" }, h("li", null, "Apples"), h("li", null, "Plums"));
reconcile(root, current, next);
current = next;
The second call only replaces the text node "Pears" with "Plums". The ul and the first li are untouched. New children are appended in order, and leftover old children are removed from the end so removals don't shift the indexes of nodes still to be visited.
What this toy lacks is exactly what makes React's version interesting: keys for matching moved children, components with state, batching all DOM changes into a single commit, and the ability to pause work in the middle. But the shape of the algorithm is the same.
Is the Virtual DOM Fast?
It's fast enough, and it's predictable. Creating element objects and diffing them costs CPU time that direct DOM manipulation wouldn't need. Frameworks like Svelte and Solid skip the virtual DOM entirely by compiling templates to precise DOM updates, and they can beat React in benchmarks.
React's bet is different: the virtual DOM gives you a simple programming model where you describe the whole UI for the current state, and React figures out the minimal changes. For most apps, the overhead is small compared to network time and layout. When it isn't, the fix is usually rendering less, not avoiding the virtual DOM:
- Keep state close to where it's used so updates re-render smaller subtrees.
- Use
memoto skip re-rendering components whose props didn't change, as explained in preventing unnecessary re-renders with React.memo. - Let the React Compiler memoize automatically.
- Virtualize long lists so only visible rows exist in the tree.
Note that "re-render" means React called your component and diffed its output. It doesn't mean the DOM changed. A component can re-render a hundred times and produce zero DOM mutations if its output is the same.
Common Misconceptions About the Virtual DOM
- "The virtual DOM makes DOM updates faster." It makes them fewer. Each DOM operation costs the same as if you'd written it by hand.
- "A re-render repaints the screen." A re-render only produces elements. The browser repaints only if the commit changed something.
- "Changing props resets state." It doesn't. Only a change in type, position, or key resets state.
- "React compares the virtual DOM to the real DOM." It compares the new element tree to the previous one, stored in its Fiber tree. It doesn't read the real DOM to diff.
- "Keys are only for performance." Keys are about identity. Wrong keys cause wrong state, not just slow renders.
Frequently Asked Questions (FAQ) About the Virtual DOM and Reconciliation
The real DOM is the browser's tree of nodes that it lays out and paints. The virtual DOM is React's tree of lightweight JavaScript objects describing what the UI should look like. React compares virtual trees and then applies only the differences to the real DOM.
Reconciliation is the process of comparing the new element tree from a render with the previous one to decide what changed. It determines which components keep their state, which are mounted or unmounted, and which DOM updates to apply during the commit.
Usually because its identity changed. Its parent element type may have changed, the component may be defined inside another component, or its key changed. React treats any of these as a new component and mounts it with fresh state.
Because it's the same type at the same position, so React reuses the instance and only passes new props. If the state should reset when an id changes, pass that id as the key prop.
No. It starts from the component whose state changed and only re-renders that subtree. Components that bail out, for example memoized components with unchanged props, are skipped along with their children.
Yes, on the client. Server Components render on the server into a serialized description of the UI, which the client turns into elements and reconciles into the existing tree like any other update.
Conclusion
The virtual DOM is a tree of plain element objects that describes your UI, and reconciliation is how React compares one version of that tree with the next. Different types replace whole subtrees, same host types get their attributes patched, same components re-render with new props and keep their state, and children are matched by position unless keys say otherwise. Identity, made of type, position, and key, decides whether state lives or dies.
Once you think in those terms, many React quirks become tools. Use key to reset state on purpose, keep component definitions at the module level, and wrap elements carefully when state needs to survive. Next, look at how keys work in lists in detail, then at Fiber, the engine that runs reconciliation in small, interruptible pieces.


