Lesson 56·M6 React Fundamentals
The Virtual DOM
OpenLearning Objectives
By the end of this lesson you should be able to:
- Define the virtual DOM in one sentence that doesn't say "fast"
- Walk through the render → diff → commit pipeline
- Explain why it is not automatically faster than direct DOM updates
- Connect reconciliation cost to when re-renders actually hurt
- Say what the real DOM has to do with any of it
One-Line Definition
The virtual DOM is a plain-JavaScript description of the UI that React diffs against the previous description to decide what changed — and only the changes are applied to the real DOM.
It's not a copy of the DOM in the browser's memory. It's a lightweight tree of plain objects, created fresh on every render, that exists so React never has to touch the real DOM to figure out what changed.
Mental Model
You're painting a wall and want to change one brick. Two strategies:
- Imperative (vanilla JS): you know exactly which brick — walk over, repaint just that one.
- Declarative (React): you don't. You describe the whole wall twice ("before" and "after"), diff the two descriptions with your eyes, and only repaint the bricks that differ.
The diffing eyes are the virtual DOM. The repainting is the commit. Describing the whole wall twice sounds wasteful — and sometimes it is, which is exactly why "it's fast" is the wrong answer. What you buy with the waste is never having to think about which brick changed: you just say what the wall should look like.
Visual Flow
render() / state change
│
▼
UI description A (new virtual tree)
│
▼
diff against B (previous virtual tree) ← pure JS, no DOM touched
│
▼
patch list: [add <li id="c">, update text of #title, nothing else]
│
▼
commit → apply ONLY the patch to the real DOM ← the slow part, done once, minimally
│
▼
B := A (next render diffs against this)How It Works
React never diffs the real DOM. It diffs two plain-JS trees. Here's the whole mechanism, from the render you already know (Lesson 51) down to the browser's paint:
render() your component returns JSX → a tree of plain objects
│
▼
virtual DOM (new) { type: 'div', props: { className: 'card' }, children: [...] }
│
▼
diff vs virtual DOM (old) walk both trees in one pass, compare type + props + keys
│
▼
patch list only the differences survive this walk
│
▼
commit (to real DOM) createNode / updateProps / removeNode — the only DOM calls
An element is just an object: React.createElement('li', { key: 'c' }, 'Three'). A reconciler
— in React 18+ the Fiber reconciler from Lesson 51 — walks the previous and current trees
together. Same element type in the same place: recurse into children. Different type: tear down
the subtree and build a new one (that's why changing an element's type resets its DOM, and why
key tells React two children are the same entity even when they moved — Lesson 52).
A minimal runnable flavour of the diff:
const oldTree = {
type: 'ul',
children: [
{ type: 'li', props: { key: 'a' }, children: ['One'] },
{ type: 'li', props: { key: 'b' }, children: ['Two'] },
],
};
const newTree = {
type: 'ul',
children: [
{ type: 'li', props: { key: 'a' }, children: ['One'] },
{ type: 'li', props: { key: 'c' }, children: ['Three'] },
],
};
function diff(oldNode, newNode) {
if (!oldNode) return { op: 'create', node: newNode };
if (!newNode) return { op: 'remove', node: oldNode };
if (oldNode.type !== newNode.type) return { op: 'replace', node: newNode };
const textOld = String(oldNode.children?.[0] ?? '');
const textNew = String(newNode.children?.[0] ?? '');
if (textOld !== textNew) return { op: 'updateText', node: newNode };
return { op: 'none' };
}
console.log(diff(oldTree.children[0], newTree.children[0])); // same li 'a'
console.log(diff(oldTree.children[1], newTree.children[1])); // text 'Two' → 'Three'
console.log(diff(null, { type: 'li', props: { key: 'd' }, children: ['Four'] })); // new node
Output:
{ op: 'none' }
{ op: 'updateText', node: { type: "li", props: { key: "c" }, children: ["Three"] } }
{ op: 'create', node: { type: "li", props: { key: "d" }, children: ["Four"] } }
Real reconciliation is smarter about props, events, and lists, but the shape is exactly this: a pure function from two trees to a list of changes, with the real DOM touched only for what that list says.
Real Project Usage
The virtual DOM is invisible — you never touch it. What you feel is its contract:
- Write the whole UI every render. No
innerHTML = ...fragments, noquerySelectorbookkeeping. State changes (Lesson 50), re-render (Lesson 51), diff, patch. - List edits are the one place you steer it. A
key(Lesson 52) tells the diff that a moved or reordered row is still the same row. Without it, React falls back to position-based matching — same diff, wrong patch. - Third-party widgets fight it. Libraries that imperatively own their DOM (maps, editors, players) don't fit the diff model — the same reason uncontrolled inputs (Lesson 54) exist.
The diff is also what the DevTools "Profiler" shows you: commits are the bars. A long bar is a slow diff, and the fix is usually rendering less (fewer components re-rendering), not making the diff smarter.
Interview Explanation
"The virtual DOM is a plain-object description of the UI that React renders, diffs against the previous one, and uses to decide the minimal changes to apply to the real DOM. It's a declarative diffing layer, not a speed hack — React re-renders and diffs on every update, and the diff itself costs work. The win is that your code never touches the DOM directly; you describe the UI and React patches the smallest change. The whole thing only pays off when the work you save by not hand-managing the DOM is bigger than the diff you pay for — which is why we also talk about keys, memo, and when not to re-render."
Senior-Level Insights
- Say "it is not automatically fast" before you're asked. The diff is O(n) over the tree, the commit is bounded by what changed — but the render that feeds the diff runs for every updated component, and that's where real cost lives. Knowing that re-frames performance questions (Lesson 51, Lesson 71) around render scope, not the diff.
- Separate render, diff, commit in your vocabulary. "What happens when state changes?" is asking about all three. Interviewees who merge them sound rehearsed; naming each phase shows you've traced the pipeline.
- Know the key heuristic cold. The diff matches by type and key; a missing key degrades it to positional matching, which patches the wrong DOM nodes. "Keys exist to make the diff cheaper and correct" is the complete answer.
- The honest "why" is developer experience, not speed. The virtual DOM buys you declarative code, correctness, and cross-browser consistency. Performance is a sometimes — the answer that says so is the one that reads as real experience.
Common Mistakes
"It's faster than direct DOM manipulation." The classic wrong answer. A hand-optimised
imperative update — one textContent change — beats a render + diff + commit, and in a
virtual DOM you pay the diff even for the one-field change. What's true: it avoids the worst
DOM patterns (full re-renders via innerHTML) and makes typical updates cheap enough that you
don't have to hand-optimise. Say that instead.
"It's a copy of the real DOM." It isn't; the two trees have a different shape. The real DOM is a living object graph with layout and style state; the virtual tree is plain data, discarded after each commit. Only the reconciled changes ever reach the DOM.
Using index keys (Lesson 52). An index changes when a list reorders, so the diff "reuses" the wrong DOM nodes — inputs keep the wrong values, focus jumps. Keys are how you make the diff's reuse decision correct.
Treating reconciliation as free. If a giant tree re-renders every keystroke, you pay a giant diff every keystroke. The diff scales with what you render — that's why scope matters.
Best Practices
✅ Describe the whole UI each render; let the diff find the changes
✅ Give list items stable, unique keys — that's steering the diff (Lesson 52)
✅ Keep re-render scope small: state as low as possible (Lesson 55), memo only when measured (Lesson 61, Lesson 71)
✅ Read the DevTools Profiler — bars are commits, long bars are too much diff
✅ Use createRoot(...).render(...) (or framework tooling) — not createElement by hand
❌ Don't claim the virtual DOM is inherently faster — say "avoids the worst DOM patterns"
❌ Don't hand-patch the DOM alongside React (innerHTML / direct style writes) — two sources of truth for the same nodes
Interview Questions
Q1. What is the virtual DOM?
A plain-JavaScript description of the UI, produced by React on every render. React diffs it against the description from the previous render and turns only the differences into real DOM updates. It's a diffing layer for declarative UI — not a copy of the DOM and not, by itself, a performance guarantee.
Q2. Why is it not automatically "fast"?
Because every update pays a full render plus a full diff, and the diff costs work that a hand-written imperative update can skip entirely. For a one-field change, direct DOM manipulation can be faster. What the virtual DOM does is make typical updates cheap enough to ignore and prevent the catastrophic patterns like re-rendering everything through
innerHTML. The win is developer experience and correctness; speed is conditional.
Q3. How does React know what changed?
By diffing the current virtual tree against the previous one, one pass, comparing element type, props, and keys. Same type and key means "reuse the DOM node and update what changed"; different type or key means "tear down and rebuild". The result is a list of patches — creates, updates, removals — which are then applied to the real DOM in the commit phase.
Q4. What role do keys play in the diff?
Keys tell the diff that two elements are the same entity even if they moved or were reordered, so it can reuse their DOM nodes. Without them the diff falls back to position, which patches the wrong nodes — that's how list inputs end up with the wrong values. Keys keep the diff both correct and cheap.
Senior follow-up: When is the virtual DOM a net loss, and what do you do about it?
When the render-and-diff cost outweighs the DOM work it saves — a huge tree re-rendering for a tiny change, or a highly dynamic tree whose diff is large. The fixes are about render scope, not the diff: keep state low and local (Lesson 55), memoise components whose props rarely change (Lesson 61, Lesson 67), and avoid index keys so reuse stays correct. In extreme cases — virtualised lists, imperative widgets — you drop out of the model entirely.
Follow-Up Questions
Q. Is the virtual DOM the same as the shadow DOM?
No. The shadow DOM is a browser feature for encapsulating real DOM subtrees — style and markup scoped to a component, no diffing involved. The virtual DOM is an in-memory data structure React diffs. Same words, unrelated mechanisms.
Q. Does every state change rebuild the whole virtual tree?
Every component that re-renders produces a new subtree description, yes — that's cheap, it's plain object allocation. The expensive part is what the diff then has to walk, which is why render scope (Lesson 51) determines the cost.
Q. How does this relate to the "reconciliation" in Lesson 51?
Reconciliation is the name of the whole process: deciding which component instances update (by render), then computing the DOM changes (by diffing virtual trees). The virtual DOM is the data structure reconciliation walks. "What happens when state changes" and "what is the virtual DOM" are two angles on the same pipeline.
Comparison Table
| Virtual DOM | Real DOM | |
|---|---|---|
| What it is | plain JS objects describing the UI | the browser's live node tree |
| Created by | React, per render | the browser |
| Cost to touch | cheap (object allocation) | expensive (layout, style, paint) |
| Diffed | yes — that's its job | never |
| Persists | only between renders | lives until removed |
| User code touches it | never directly | via refs and imperative widgets |
Code Example
Watch the full loop — render, diff, commit — with a plain-JS stand-in that logs each phase:
function renderView(state) {
return { items: state.items, total: state.items.length };
}
const previous = renderView({ items: ['a', 'b'] });
const current = renderView({ items: ['a', 'b', 'c'] });
// diff — pure JS, no DOM anywhere in the process
function diff(prev, next) {
const patches = [];
if (prev.total !== next.total) patches.push(`total: ${prev.total} → ${next.total}`);
if (prev.items.length !== next.items.length) {
patches.push(`items: ${prev.items.length} → ${next.items.length}`);
}
return patches;
}
const patches = diff(previous, current);
// commit — the only part that would touch the DOM
console.log('render produced', JSON.stringify(current));
console.log('diff found', patches);
console.log('commit: append', current.items[current.items.length - 1]);
Output:
render produced { items: ["a", "b", "c"], total: 3 }
diff found ["total: 2 → 3", "items: 2 → 3"]
commit: append c
Exactly two values changed, so exactly two patches and one DOM operation. Nothing about the two unchanged items was touched. That discipline — never touching the DOM to find out what changed, only to apply it — is the entire value of the design.
Performance Notes
The pipeline has three costs. Render allocates a fresh subtree per re-rendering component — cheap, but it scales with how much you render. The diff walks that subtree — also cheap per node, but it scales with the tree, whether or not anything changed. The commit touches the DOM only for actual differences — the one part that can truly bite, because DOM work means layout and paint, and it is the only part React has already minimised for you.
So the honest trade-offs: controlled forms re-render on every keystroke (Lesson 54), but the
diff finds one changed value and the commit is one property set — net win. A top-level state
change that re-renders a thousand components pays a thousand-node diff to change one line —
net loss, and the answer is render scope, not a faster diff. The virtual DOM converts "how do
I surgically update the page" into "how little does React have to re-render", which is a much
more tractable question — and the subject of Lesson 71.
Debugging Scenarios
"React changed the wrong DOM node." The diff reused a node it shouldn't have — almost always a missing or index-based key in a list (Lesson 52). Give the rows a stable identity and the reuse decision becomes correct.
"My input loses focus on every keystroke." The diff is recreating the input's DOM node
each render. Check for an index key on the surrounding list or a changing type/element
identity — something is forcing a rebuild instead of an update.
"Everything flashes / resets on state change." Same mechanism, wider blast radius: a subtree is being torn down and rebuilt because its element type or key changed. Find what varies between renders and stabilise it.
"The Profiler shows one giant commit." A whole subtree re-rendered and diffed. Reduce render scope — state lower (Lesson 55), memo where props are stable (Lesson 61) — then watch the commit shrink.
Quick Revision Notes
- Virtual DOM = plain-object description of the UI; diffed, then discarded
- Render → diff → commit is the whole pipeline; the real DOM is only touched in commit
- It is a declarative diffing layer, not a speed hack — and saying so unprompted is a senior move
- Diff is O(n) over the tree; it costs work even when nothing changed
- Keys steer reuse: same type + key → reuse; different → rebuild (Lesson 52)
- Commit is the only DOM work, and it's already minimal — the cost is in render scope
- Shadow DOM is unrelated: a browser encapsulation feature, no diffing
Cheat Sheet
// you write the whole UI; React finds the difference
function List({ items }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li> // key steers the diff's reuse decision
))}
</ul>
);
}
// the diff in three words: type, props, key
// same type + same key → update in place (cheap)
// different type or key → tear down, build new (expensive — avoid by accident)
// two trees in, one patch list out, DOM only in commitKey Takeaways
Recap
- The virtual DOM is a plain-object UI description React diffs against the previous one — not a copy of the DOM
- Pipeline: render (new tree) → diff (old vs new, pure JS) → commit (only changes to the real DOM)
- It is not automatically fast: render and diff cost work on every update
- The real win is declarative code and avoiding catastrophic DOM patterns, not raw speed
- Keys keep the diff's reuse decision correct; type changes force rebuilds
- The cost that matters is render scope — how much React has to re-render, not the diff itself
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/6Define the virtual DOM in one sentence — without the word "fast".
Name the three phases and which one touches the real DOM.
Why does a one-field state change in a controlled form beat manual innerHTML rewrites — and when would direct DOM updates win?
What does the diff compare, and what decides whether a node is reused or rebuilt?
How do keys affect both the correctness and the cost of the diff?
What's the difference between the virtual DOM and the shadow DOM?
What's Next
Lesson 57 — useEffect. Your code has been described, diffed, and committed — now it's time for the side effects that run around the render, and the most misunderstood hook in React.