Skip to main content

Virtual DOM in React

Virtual DOM - React's in-memory JavaScript representation of the real DOM that enables efficient UI updates through diffing and minimal real DOM mutations.

Theory

TL;DR

  • Analogy: sketching changes on paper (Virtual DOM) before painting a wall (real DOM) - only apply the exact strokes needed
  • Main difference: direct DOM calls like document.getElementById trigger synchronous browser reflows; Virtual DOM batches changes and diffs first
  • Decision rule: frequent state updates → Virtual DOM; static pages with no interactivity → skip React entirely
  • Keys in lists let React reuse existing DOM nodes on reorder instead of remounting everything
  • React 18 fiber reconciler makes rendering interruptible, so user input stays responsive during heavy updates

Quick Example

jsx
// Counter: React diffs old and new Virtual DOM, updates only the text node import { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); return ( <div> <h1>Count: {count}</h1> {/* Only this text node changes */} <button onClick={() => setCount(count + 1)}>+1</button> </div> ); } // On click: React creates new Virtual DOM tree, diffs it against previous, // finds only <h1> text changed, patches that single text node in real DOM. // The <button> and <div> are untouched.

On every click, React builds a new JavaScript object tree representing the UI, compares it to the last one, and sends only the delta to the browser. The <button> DOM node is never touched.

How the Diffing Algorithm Works

React's diff algorithm runs in O(n) time using two heuristics. First, if two elements have different types (say, <div> becomes <span>), React tears down the entire subtree and builds fresh. No attempt to reconcile. Second, when rendering lists, key props let React track which items moved, which got added, and which were removed. Without key, React falls back to index-based comparison and remounts everything on reorder.

The reconciler (the react-reconciler package) builds a fiber tree: a linked-list structure where each node represents a component. In React 18, this work is interruptible. High-priority updates like user input can preempt a slow re-render of a data table mid-way through.

Update Flow

  1. Trigger - setState or useState fires, React schedules a re-render
  2. Render phase - React calls your components and builds a new Virtual DOM tree in memory (plain React.createElement calls returning JS objects)
  3. Commit phase - React diffs the new tree against the previous fiber tree, then applies the minimal set of DOM mutations via commitRoot

Batching matters here. React groups multiple state updates into one commit instead of flushing after each call. In React 18, batching happens automatically even inside setTimeout or native event handlers.

When to Use

  • Frequent state updates (user input, real-time data) - Virtual DOM handles batching automatically
  • Large dynamic lists - pair with key props, add react-window beyond 500 items
  • Performance tuning - add React.memo or useMemo on top of Virtual DOM diffing
  • Static HTML pages with no interactivity - skip React, direct DOM or plain HTML is faster here

Common Mistakes

Using array index as key

jsx
// Wrong: inserting at start shifts all indexes, React remounts everything items.map((item, i) => <li key={i}>{item.text}</li>) // Right: stable unique ID items.map(item => <li key={item.id}>{item.text}</li>)

Index keys work fine for static, never-reordered lists. The moment you sort, filter, or insert at the top, all keys shift and React remounts every node - losing local state, focus, and running animations.

Assuming Virtual DOM is always faster than direct DOM

For a single toggle or a static text swap, document.getElementById is faster. React's overhead comes from the diff itself. SyntheticJS benchmarks show React about 4x slower than direct DOM for trivial single-element updates, and 10x faster for complex lists where batching prevents hundreds of reflows.

Mutating state directly

jsx
// Wrong: Virtual DOM diffing relies on reference equality state.items.push(newItem); setState(state); // React sees same reference, may skip re-render // Right: new reference setState({ ...state, items: [...state.items, newItem] });

Deep nested trees without virtualization

10,000 rows rendered at once will stress the diff regardless of Virtual DOM. Use react-window or react-virtual to render only visible rows.

Re-renders cascading from parent

If a parent re-renders, all children re-render by default unless wrapped in React.memo. Virtual DOM makes this cheap, but not free. The Profiler in React DevTools shows where the cost actually lands.

Real-world Usage

  • React core - every component renders to Virtual DOM objects via jsx-runtime before any browser paint
  • Next.js - server renders a Virtual DOM tree, hydrates it on the client by matching against the live DOM
  • Preact - 3kb Virtual DOM clone used in Hulu's dashboard and 100k+ sites where bundle size matters
  • Redux DevTools - serializes Virtual DOM snapshots for time-travel debugging
  • React Native - same diffing mechanism, different commit target (native views instead of browser DOM)

Follow-up Questions

Q: How does React decide to replace a subtree vs update it?
A: Element type comparison. If <Input> becomes <Select>, React unmounts Input and mounts Select fresh. If the type stays the same, React patches only the changed props.

Q: What happens with duplicate or missing keys?
A: Missing keys fall back to index with a console warning. Duplicate keys cause wrong diffing - React picks one arbitrarily and may produce incorrect UI without throwing an error.

Q: How does React 18 concurrent mode change the Virtual DOM cycle?
A: startTransition marks updates as low priority. Fiber can suspend work on the new Virtual tree mid-way, let a higher-priority update (like a keystroke) commit first, then resume or discard the interrupted work. The Virtual DOM tree structure is the same, but the scheduler controls when it commits.

Q: Why not write manual diff logic and skip React?
A: You can. But React's key/type heuristics cover the common 99% of cases without extra code. Manual diffing becomes maintenance overhead fast, especially once lists, animations, and concurrent updates enter the picture.

Q: When would you reach for useMemo on top of Virtual DOM?
A: When a component re-renders frequently and one of its children does expensive calculation or renders a large subtree. useMemo returns a cached reference so the diff skips that subtree entirely.

Examples

Basic: Counter showing minimal DOM updates

jsx
import { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); return ( <div> <h1>Count: {count}</h1> <button onClick={() => setCount(count + 1)}>+1</button> </div> ); }

Click the button three times. React creates a new Virtual DOM tree on each click, diffs it against the previous, and patches only the text node inside <h1>. The <div> and <button> nodes in the real DOM are never touched. Open React DevTools Profiler to see exactly which component committed and how long it took.

Intermediate: Todo list with keys

jsx
import { useState } from 'react'; const initial = [ { id: 1, text: 'Learn React', done: false }, { id: 2, text: 'Build app', done: true }, ]; function TodoList() { const [todos] = useState(initial); const [filter, setFilter] = useState('all'); const visible = todos.filter(t => filter === 'all' ? true : t.done === (filter === 'done') ); return ( <> <button onClick={() => setFilter('all')}>All</button> <button onClick={() => setFilter('done')}>Done</button> <ul> {visible.map(todo => ( <li key={todo.id}>{todo.text}</li> // stable key = reuse DOM node on filter change ))} </ul> </> ); }

Switch filters and React diffs the list, reuses unchanged <li> nodes, and adds or removes only what changed. Swap key={todo.id} for key={index} and re-check the Profiler - every <li> remounts on each filter switch.

Advanced: Index keys vs stable keys under drag-reorder

jsx
// Bad: index keys cause full remounts when list reorders function BadList({ items }) { return ( <ul> {items.map((item, index) => ( <Item key={index} data={item} /> // drag to reorder → 100 unmounts + 100 mounts ))} </ul> ); } // Good: stable ID keys let React swap positions cheaply function GoodList({ items }) { return ( <ul> {items.map(item => ( <Item key={item.id} data={item} /> // drag to reorder → React swaps fiber nodes, preserves state ))} </ul> ); }

With 100 draggable items, BadList triggers 100 unmounts and 100 mounts on every reorder because every index shifts. GoodList only swaps fiber node positions. In React Native's FlatList, this difference shows up as visible flicker and lost scroll position on Android. I've seen this mistake in senior developers' PRs more often than you'd expect.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?