Skip to main content

Virtual DOM and Reconciliation

What is the Virtual DOM?

The Virtual DOM (VDOM) is a lightweight JavaScript representation of the actual DOM. React uses it to optimize UI updates by minimizing direct DOM manipulations, which are expensive operations.

Why Virtual DOM?

DOM manipulation is slow because:

  • Repainting and reflow are costly operations
  • Updating the DOM triggers browser layout recalculations
  • Direct DOM manipulation can cause performance bottlenecks

Virtual DOM benefits:

  • Fast in-memory operations
  • Batched updates
  • Efficient diffing algorithm
  • Cross-platform abstraction (React Native, React PDF, etc.)

How Virtual DOM Works

1. Initial Render

function App() {
return (
<div className='app'>
<h1>Hello, React!</h1>
<p>Count: 0</p>
</div>
);
}

Virtual DOM representation:

{
type: 'div',
props: {
className: 'app',
children: [
{
type: 'h1',
props: { children: 'Hello, React!' }
},
{
type: 'p',
props: { children: 'Count: 0' }
}
]
}
}

2. State Update

function App() {
const [count, setCount] = useState(0);

return (
<div className='app'>
<h1>Hello, React!</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

When count changes:

  1. React creates a new Virtual DOM tree
  2. Compares it with the previous Virtual DOM (diffing)
  3. Calculates minimal changes needed
  4. Updates only the changed parts in the real DOM

Reconciliation Algorithm

Reconciliation is the process React uses to diff the old and new Virtual DOM trees and determine what needs to be updated.

The Diffing Algorithm

React's diffing algorithm has O(n) complexity (where n is the number of elements) based on two assumptions:

  1. Different element types produce different trees
  2. Developer can hint which child elements are stable using keys

Element Type Changes

// Before
<div>
<Counter />
</div>

// After - Counter will be unmounted and remounted
<span>
<Counter />
</span>

When root element type changes:

  • Old tree is destroyed completely
  • Component unmounts (componentWillUnmount/useEffect cleanup)
  • New tree is built from scratch
  • Component mounts (componentDidMount/useEffect)

Same Element Type

// Before
<div className="before" title="old" />

// After
<div className="after" title="new" />

React:

  • Keeps the same DOM node
  • Only updates changed attributes
  • Recursively processes children

Component Updates

// Before
<Button color="blue" />

// After
<Button color="red" />

React:

  • Component instance stays the same
  • Props are updated
  • Component re-renders with new props
  • componentDidUpdate/useEffect runs

Keys in Lists

Keys help React identify which items have changed, been added, or removed.

Without Keys (Anti-pattern)

// ❌ Bad - using index as key
{
items.map((item, index) => <li key={index}>{item.name}</li>);
}

// Problems:
// - Reordering breaks component state
// - Performance issues
// - Incorrect re-renders

With Stable Keys

// ✅ Good - using unique ID
{
items.map(item => <li key={item.id}>{item.name}</li>);
}

Key Example - Why It Matters

function TodoList() {
const [todos, setTodos] = useState([
{ id: '1', text: 'Learn React', done: false },
{ id: '2', text: 'Build App', done: false },
{ id: '3', text: 'Deploy', done: false },
]);

const removeTodo = id => {
setTodos(todos.filter(todo => todo.id !== id));
};

return (
<ul>
{/* ✅ Correct - stable keys */}
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onRemove={() => removeTodo(todo.id)}
/>
))}
</ul>
);
}

// Component state is preserved correctly during reordering
function TodoItem({ todo, onRemove }) {
const [isEditing, setIsEditing] = useState(false);

return (
<li>
{isEditing ? (
<input defaultValue={todo.text} />
) : (
<span>{todo.text}</span>
)}
<button onClick={() => setIsEditing(!isEditing)}>Edit</button>
<button onClick={onRemove}>Remove</button>
</li>
);
}

React Fiber Architecture (React 16+)

Fiber is React's reconciliation engine rewrite that enables:

1. Incremental Rendering

Break rendering work into chunks and spread it over multiple frames

// React can pause work and come back to it later
function HeavyComponent({ items }) {
return (
<ul>
{items.map(item => (
<HeavyListItem key={item.id} item={item} />
))}
</ul>
);
}

2. Priority-based Updates

// High priority - user input
<input onChange={handleChange} />

// Low priority - large list update
<ExpensiveList items={items} />

3. Concurrent Features (React 18+)

import { useTransition, useDeferredValue } from 'react';

function SearchResults({ query }) {
const [isPending, startTransition] = useTransition();
const deferredQuery = useDeferredValue(query);

// Mark expensive updates as transitions
const handleSearch = value => {
startTransition(() => {
setQuery(value);
});
};

return (
<>
<input onChange={e => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<Results query={deferredQuery} />
</>
);
}

Render Phases

1. Render Phase (Pure, can be paused)

  • Call component functions
  • Calculate Virtual DOM
  • Perform diffing
  • Can be interrupted
function Component() {
// This runs during render phase
const value = expensiveCalculation();

return <div>{value}</div>;
}

2. Commit Phase (Cannot be interrupted)

  • Apply changes to DOM
  • Run useLayoutEffect
  • Run componentDidMount/Update
  • Browser paints
function Component() {
useLayoutEffect(() => {
// Runs synchronously after DOM mutations
measureElement();
});

useEffect(() => {
// Runs after browser paint
fetchData();
});

return <div>Content</div>;
}

Optimization Techniques

1. React.memo - Prevent Unnecessary Re-renders

// Without memo - re-renders every time parent renders
function ExpensiveComponent({ data }) {
return <div>{expensiveOperation(data)}</div>;
}

// With memo - only re-renders when props change
const ExpensiveComponent = React.memo(({ data }) => {
return <div>{expensiveOperation(data)}</div>;
});

// Custom comparison function
const ExpensiveComponent = React.memo(
({ data }) => <div>{expensiveOperation(data)}</div>,
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.data.id === nextProps.data.id;
}
);

2. useMemo - Memoize Expensive Calculations

function Component({ items, filter }) {
// ❌ Recalculates on every render
const filteredItems = items.filter(item => item.category === filter);

// ✅ Only recalculates when dependencies change
const filteredItems = useMemo(
() => items.filter(item => item.category === filter),
[items, filter]
);

return <List items={filteredItems} />;
}

3. useCallback - Memoize Function References

function Parent() {
const [count, setCount] = useState(0);

// ❌ New function on every render
const handleClick = () => {
console.log('clicked');
};

// ✅ Stable function reference
const handleClick = useCallback(() => {
console.log('clicked');
}, []);

// Child won't re-render unnecessarily
return <ExpensiveChild onClick={handleClick} />;
}

const ExpensiveChild = React.memo(({ onClick }) => {
return <button onClick={onClick}>Click me</button>;
});

4. Key Optimization - Preserve Component State

function UserProfile({ userId }) {
// Component will remount when userId changes
// State is reset, effects re-run
return <ProfileContent key={userId} userId={userId} />;
}

Virtual DOM vs Real DOM Update

function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<h1>Counter App</h1>
<p>Current count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

When count updates from 0 to 1:

Virtual DOM Diff:
└── <div> (unchanged)
├── <h1> (unchanged)
├── <p> (changed)
│ └── text: "Current count: 0" → "Current count: 1"
└── <button> (unchanged)

Real DOM Update:
→ Only the text node inside <p> is updated
→ Other elements remain untouched

React 18 Concurrent Features

Automatic Batching

// React 17 - Two separate renders
function handleClick() {
setCount(c => c + 1); // Render 1
setFlag(f => !f); // Render 2
}

// React 18 - Batched into one render
function handleClick() {
setCount(c => c + 1); // \
setFlag(f => !f); // / Batched - single render
}

// Even in async code
async function handleClick() {
await fetchData();
setCount(c => c + 1); // \
setFlag(f => !f); // / Batched - single render
}

Transitions

import { useTransition } from 'react';

function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();

const handleSearch = value => {
// Urgent update - keep input responsive
setQuery(value);

// Non-urgent update - can be interrupted
startTransition(() => {
const filtered = expensiveSearch(value);
setResults(filtered);
});
};

return (
<>
<input value={query} onChange={e => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
);
}

Best Practices

  1. Use Keys Properly - Stable, unique identifiers
  2. Avoid Index as Key - Unless list is static and never reordered
  3. Minimize Re-renders - Use memo, useMemo, useCallback wisely
  4. Keep Components Pure - No side effects in render
  5. Use Transitions - For non-urgent updates in React 18+
  6. Profile Performance - Use React DevTools Profiler

Common Mistakes

// ❌ Creating new objects/arrays in render
function Component({ user }) {
return <ChildComponent user={{ ...user }} />; // New object every render
}

// ✅ Pass props directly
function Component({ user }) {
return <ChildComponent user={user} />;
}

// ❌ Inline function with memo
const Child = React.memo(({ onClick }) => <button onClick={onClick} />);
<Child onClick={() => handleClick()} />; // New function every render

// ✅ useCallback for stable reference
const handleClick = useCallback(() => doSomething(), []);
<Child onClick={handleClick} />;

Practice Exercises

  1. Build a todo list and observe how React handles updates when adding/removing items
  2. Create a large list (1000+ items) and optimize rendering with virtualization
  3. Use React DevTools Profiler to identify performance bottlenecks
  4. Implement a search feature with transitions for better UX

Next Steps