how the Virtual DOM really works
Anyone who only knows React on the surface knows that the Virtual DOM "makes things fast". Anyone who wants to build performant React apps needs to understand how the diffing algorithm decides what gets updated, and why keys, component identity and the Fiber architecture are the decisive factor.
Table of Contents
- 1. What the Virtual DOM really is
- 2. React Fiber: the architecture behind reconciliation
- 3. The diffing algorithm: two heuristics
- 4. Keys: setting identity in lists correctly
- 5. Component identity and mount/unmount
- 6. Bailout mechanisms: when React skips rendering
- 7. Using React.memo, useMemo and useCallback correctly
- 8. React DevTools Profiler: reading the flamegraph
- 9. Reconciliation optimizations compared
- 10. Summary
- 11. FAQ
1. What the Virtual DOM really is
The Virtual DOM is not a magical performance layer, it is a simple JavaScript data structure: a tree of plain objects that describe DOM nodes. When React calls render(), a new VDOM tree is created. This new tree is compared to the previous one, a process called reconciliation. The result is a minimal list of actual DOM operations that get applied. The advantage over writing to the DOM directly is not the speed of the VDOM itself, but the bundling of many individual changes into one controlled update cycle.
That is also why the Virtual DOM is not always faster than direct DOM manipulation: for simple, predictable updates, writing to the DOM directly is faster. The VDOM approach wins with complex state machines, where many parts of the UI react to the same state change, because React batches these updates, prioritizes them, and only performs the minimally necessary DOM changes. Understanding what happens in this process is the prerequisite for building performant React apps.
2. React Fiber: the architecture behind reconciliation
React Fiber is the reimplementation of the reconciliation algorithm introduced with React 16. The core problem of the old stack reconciler was that it was synchronous and non-interruptible: once started, a render cycle ran through to the end and blocked the browser thread. For large component trees, this could cause noticeable jank. Fiber solves this with a finely granular unit system: each component corresponds to a fiber node, and the work can be split into small units, prioritized, and spread across browser frames.
A fiber is a JavaScript object instance that holds the current state of a component: props, state, the associated DOM node, references to child, sibling and parent fibers, as well as the type of the corresponding component. React maintains two fiber trees at the same time: the current tree (what is currently in the DOM) and the workInProgress tree (what is currently being computed). After a render cycle completes successfully, the roles of the two trees are swapped, the so-called double buffering technique. This enables interruptible rendering without ever letting the user see a half-finished state.
// Demonstrating React reconciliation behavior with keys
// Without key: React reuses DOM node, just updates text content
function WithoutKey() {
const [show, setShow] = React.useState(true);
return (
<div>
{show ? <input placeholder="First input" /> : <input placeholder="Second input" />}
<button onClick={() => setShow(s => !s)}>Toggle</button>
</div>
);
// Problem: same type at same position → React keeps the DOM node
// Input state (typed text) persists when toggling, unexpected behavior
}
// With key: React unmounts/remounts, DOM node is fresh
function WithKey() {
const [show, setShow] = React.useState(true);
return (
<div>
{show
? <input key="first" placeholder="First input" />
: <input key="second" placeholder="Second input" />
}
<button onClick={() => setShow(s => !s)}>Toggle</button>
</div>
);
// Different keys → different identity → React unmounts old, mounts new
// Input state resets on toggle, correct behavior
}
3. The diffing algorithm: two heuristics
A full comparison of two trees would have a time complexity of O(n3). React instead uses two heuristics that bring it down to O(n). The first heuristic: two elements of different types produce different trees. If a <div> is replaced by a <section>, React discards the entire subtree and rebuilds it from scratch. The same applies to component swaps at the same position: replacing <ComponentA /> with <ComponentB /> means all child components and their state are lost.
The second heuristic: the developer can assign stable identities across renders using the key prop. Without keys, React compares elements in lists position by position. If an element is inserted at the beginning, React thinks all following elements have "changed" and re-renders all of them, even though only one element was added. With correct keys, React recognizes which elements stayed the same, which were moved, and which are new, and performs only the minimally necessary DOM operations.
4. Keys: setting identity in lists correctly
The key prop is the most important optimization for lists in React, and at the same time the most frequently misused. The most common mistake is using the array index as key: key={index}. This only works correctly if the list is never sorted, filtered, or reordered. As soon as elements get moved, index keys cause React to misidentify elements, it thinks an element has changed when it was only moved. This leads not only to performance problems but also to incorrect state retention.
The correct key is a stable, unique ID taken from the data itself: key={item.id}. When the data comes from a backend, it almost always has a UUID or a numeric ID. If no stable ID exists, one must be generated when the data is loaded, for example with crypto.randomUUID() or a library generator. The key only needs to be unique among sibling elements, not globally. A key that changes between renders is just as bad as no key at all: React unmounts and remounts the component on every render.
5. Component identity and mount/unmount
React decides on the identity of a component using two criteria: position in the tree and type of the component (or key, if set). If a component has the same type at the same position, React keeps its instance and its state. This has an important consequence: if a component is conditionally rendered and its position in the tree does not change, its state is retained, even if the props have changed completely.
This behavior is often surprising. A common example: a form component is rendered with a different userId prop but keeps the state from the previous user session, because it sits at the same position. The solution is an explicit key={userId}, which signals to React that this is a new instance that should start fresh. This is a deliberate use of the key mechanism, not for list optimization but for state control.
// Controlling mount/unmount via key for state reset
// Problem: ProfileForm keeps stale state when userId changes
function BadExample({ userId }: { userId: string }) {
return <ProfileForm userId={userId} />;
// React sees: same type, same position → keeps instance and state
// Old form data persists when switching users
}
// Solution: key forces new instance when userId changes
function GoodExample({ userId }: { userId: string }) {
return <ProfileForm key={userId} userId={userId} />;
// New key → React unmounts old, mounts fresh instance
// State resets cleanly on userId change
}
// useEffect-based alternative (when remounting is too expensive)
function AlternativeExample({ userId }: { userId: string }) {
const [formData, setFormData] = React.useState(getInitialData(userId));
React.useEffect(() => {
// Reset state when userId changes without remounting
setFormData(getInitialData(userId));
}, [userId]);
return <ProfileForm data={formData} onChange={setFormData} />;
}
6. Bailout mechanisms: when React skips rendering
React has several mechanisms to skip re-rendering a component, so-called bailouts. The simplest: if useState or useReducer sets the same value as before (after an Object.is comparison), React does not re-render the component. This is why setState(sameValue) does not trigger a re-render. With object and array state, this is important to understand: setState(obj) where obj has the same reference does not trigger a re-render.
The second bailout mechanism is React.memo: the entire component is skipped with a shallow comparison of its props if no props have changed. The third mechanism is React's own automatic bailout optimization: when a parent component renders, React by default renders all child components too, but React 18 introduces automatic batching, which combines multiple state updates into a single render. useTransition allows marking low-priority updates that React defers when higher-priority work is pending.
7. Using React.memo, useMemo and useCallback correctly
React.memo, useMemo and useCallback are frequently applied everywhere out of a misguided performance-optimization reflex. This is counterproductive: every use of these hooks has its own cost, memory usage for cached values, CPU time for comparison operations, and increased code complexity. They are not a free optimization, they are a trade-off. Using them correctly requires understanding when they actually help.
React.memo helps when a component performs expensive render work and is often called with the same props. useMemo helps with expensive computations that would otherwise be redone on every render, not with simple object literals. useCallback helps when a function is passed as a prop to a memo-wrapped component and needs a stable reference. Both are useless without React.memo in the child component, since React re-renders anyway without a memo wrapper. The rule of thumb: profile first, then optimize, not the other way around.
8. React DevTools Profiler: reading the flamegraph
The React DevTools Profiler is the most important tool for understanding reconciliation in real applications. It shows in a flamegraph which components were rendered on which render, how long each component took, and why it rendered, whether due to a props change, a state change, or because of the parent element. The "why" aspect is the most valuable: it shows whether a component renders unnecessarily because a function or an object gets a new reference every time.
The flamegraph colors are intuitive: gray bars mean the component was not rendered (bailout). Colored bars show rendered components, with more intense colors indicating longer render times. When profiling, you should always test against a production build: React's development mode contains additional checks that distort the profile. With the Profiler component from React itself, profiling can also be done programmatically within your own app, and values can be written to monitoring.
9. Reconciliation optimizations compared
Reconciliation optimizations work at different levels. The following table gives an overview of which technique solves which problem and when it should be used.
| Technique | Solves which problem | Cost | When to use |
|---|---|---|---|
| Stable keys | Wrong identity in lists | None | Always for lists, no exception |
| React.memo | Unnecessary re-renders caused by parent | Props comparison on every render | Expensive components with stable props |
| useCallback | Unstable function references as props | Dep comparison plus closure memory | Only with memo in child component |
| useMemo | Expensive computations per render | Dep comparison plus storage for value | Demonstrably expensive computations |
| key for reset | State persistence across props changes | Unmount plus mount | When a useEffect reset becomes too complex |
The key takeaway from this table: stable keys cost nothing and should always be used. The other techniques come at a cost and should only be applied after a profiling analysis. Premature optimization with memo and useMemo increases code complexity without delivering measurable performance gains, and it obscures real bottlenecks.
Mironsoft
React Performance · Reconciliation · Profiling · Optimization
Your React app noticeably slow? We find the cause.
We analyze your React app with the DevTools Profiler, identify unnecessary re-renders and implement targeted reconciliation optimizations, without a blind memo-everywhere strategy.
Profiling session
Flamegraph analysis and identification of the most expensive render paths in your app
Key audit
Tracking down index keys and replacing them with stable IDs, the simplest optimization
Memo strategy
Using React.memo, useMemo and useCallback in a targeted, demonstrably effective way
10. Summary
React reconciliation is the process by which React computes minimal DOM changes from state updates. The Fiber architecture makes this process interruptible and prioritizable. The diffing algorithm uses two heuristics: type changes destroy the subtree, and keys define stable identities across renders. Wrong or missing keys in lists are the most common source of reconciliation bugs and unnecessary re-rendering.
Bailout mechanisms like React.memo, useMemo and useCallback are not a free speed booster, they are targeted tools that should be applied after a profiling analysis. The most important thing for performant React apps: open the profiler, understand the actual problem, and then apply the minimal optimization, stable keys, targeted memos and clear component boundaries are sufficient in most cases.
React Reconciliation, the essentials at a glance
Virtual DOM
Tree of JS objects compared to the previous tree. Reconciliation yields minimal DOM operations. Not always faster than direct DOM.
Fiber architecture
Every component is a fiber node. Double buffering with current/workInProgress tree. Interruptible, prioritized rendering since React 16.
Keys
Stable ID from the data, never the array index. Key change means unmount plus mount. Use the key trick to reset state on props change.
Memo strategy
Profile first, then optimize. React.memo plus useCallback together. useMemo only for demonstrably expensive computations. No blind memo-everywhere strategy.