DEV Community

Tihomir Ivanov
Tihomir Ivanov

Posted on

Reconciliation & The Virtual DOM: How React Decides What to Update

You've probably heard that React is "fast because of the Virtual DOM." But what does that actually mean? And more importantly, why do you need to add a key prop to list items?

The answer lies in React's reconciliation algorithm — the intelligent system that decides which parts of the DOM need updating when your component state changes.

Understanding this isn't just academic — it's critical for writing performant React applications.

The Golden Rule

React uses a Virtual DOM (a lightweight JavaScript representation of the real DOM) and a reconciliation algorithm to efficiently determine the minimum set of changes needed to update the UI. The key prop helps React identify which items have changed, been added, or removed in lists.

In simpler terms: React compares the old and new Virtual DOM trees, calculates the differences (diffing), and applies only those changes to the real DOM.

Let's understand how and why this works.


Part 1: The Problem with Direct DOM Manipulation

Why Updating the DOM is Slow

The DOM (Document Object Model) is a tree structure that browsers use to represent HTML. Updating it directly is expensive because:

  1. Layout recalculation — Browser must recalculate positions and sizes
  2. Repaint — Browser must redraw pixels on screen
  3. Reflow — Changes can trigger cascading updates to other elements

Example of inefficient DOM updates:

// Bad: Direct DOM manipulation
for (let i = 0; i < 1000; i++) {
  const div = document.createElement('div');
  div.textContent = `Item ${i}`;
  document.body.appendChild(div); // Triggers reflow 1000 times!
}
Enter fullscreen mode Exit fullscreen mode

Each appendChild causes a reflow, making this extremely slow.


React's Solution: Batch Updates

React batches changes in memory (Virtual DOM) and applies them all at once:

// Good: React batches updates
function ItemList({ items }) {
  return (
    <div>
      {items.map((item, i) => (
        <div key={i}>Item {i}</div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Result: React creates a Virtual DOM tree, diffs it with the previous version, and applies all changes in one batch to the real DOM.


Part 2: What is the Virtual DOM?

The Virtual DOM is a lightweight JavaScript object that represents the structure of your UI.

Real DOM vs Virtual DOM

Real DOM:

<div id="root">
  <h1>Hello</h1>
  <p>World</p>
</div>
Enter fullscreen mode Exit fullscreen mode

Virtual DOM (simplified representation):

{
  type: 'div',
  props: { id: 'root' },
  children: [
    {
      type: 'h1',
      props: {},
      children: ['Hello']
    },
    {
      type: 'p',
      props: {},
      children: ['World']
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Key Point: The Virtual DOM is just a JavaScript object — it's fast to create, compare, and manipulate.


React Elements

When you write JSX:

const element = <h1 className="greeting">Hello, world!</h1>;
Enter fullscreen mode Exit fullscreen mode

React transforms it into:

const element = React.createElement(
  'h1',
  { className: 'greeting' },
  'Hello, world!'
);
Enter fullscreen mode Exit fullscreen mode

Which creates a Virtual DOM object:

{
  type: 'h1',
  props: {
    className: 'greeting',
    children: 'Hello, world!'
  }
}
Enter fullscreen mode Exit fullscreen mode

Part 3: The Reconciliation Process

When a component's state or props change, React:

  1. Renders the component to create a new Virtual DOM tree
  2. Diffs (compares) the new tree with the previous one
  3. Commits the changes to the real DOM

This three-step process is called reconciliation.


Step 1: Render Phase

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

When you click the button:

  1. setCount triggers a re-render
  2. React calls Counter() again
  3. A new Virtual DOM tree is created with the updated count

Step 2: Diffing Algorithm

React compares the new Virtual DOM with the previous one using a smart diffing algorithm.

Assumption 1: Different Types = Completely Rebuild

// Old tree
<div>
  <Counter />
</div>

// New tree
<span>
  <Counter />
</span>
Enter fullscreen mode Exit fullscreen mode

Result: React destroys the <div> and its children completely, then creates a new <span> tree from scratch.

Why? Elements of different types (div vs span) are unlikely to produce similar trees, so React doesn't waste time comparing their children.


Assumption 2: Same Type = Update Props

// Old
<div className="before" />

// New
<div className="after" />
Enter fullscreen mode Exit fullscreen mode

Result: React keeps the same DOM node and only updates the className attribute.


Step 3: Commit Phase

React applies the calculated changes to the real DOM in a single batch:

// React's internal operations (pseudocode)
domNode.className = 'after'; // Update attribute
textNode.textContent = 'New text'; // Update text
parentNode.appendChild(newNode); // Add new node
Enter fullscreen mode Exit fullscreen mode

Key Optimization: React applies only the necessary changes, not a full re-render of the entire DOM.


Part 4: The Importance of key in Lists

The key prop is React's way of tracking which items have changed in a list.

Without key (or with index as key)

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>{todo.text}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Problem: If you add/remove/reorder items, React can't efficiently determine what changed.

Example:

// Before
['Buy milk', 'Walk dog', 'Read book']

// After (removed 'Walk dog')
['Buy milk', 'Read book']
Enter fullscreen mode Exit fullscreen mode

Without unique keys, React sees:

  • Index 0: 'Buy milk' → 'Buy milk' (no change)
  • Index 1: 'Walk dog' → 'Read book' (update text)
  • Index 2: 'Read book' → deleted

Result: React updates index 1 instead of just removing index 1 — inefficient!


With Unique key

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now React sees:

  • Key 'milk': 'Buy milk' → 'Buy milk' (no change)
  • Key 'dog': deleted
  • Key 'book': 'Read book' → 'Read book' (no change)

Result: React efficiently removes the correct item without updating the others.


Why Index as key is Dangerous

Using array index as key breaks when items are reordered:

// Before
const items = [
  { id: 'a', name: 'Alice' },
  { id: 'b', name: 'Bob' }
];

// After (reversed)
const items = [
  { id: 'b', name: 'Bob' },
  { id: 'a', name: 'Alice' }
];
Enter fullscreen mode Exit fullscreen mode

With key={index}:

  • Index 0: Alice → Bob (React updates text)
  • Index 1: Bob → Alice (React updates text)

With key={item.id}:

  • Key 'a': Alice stays Alice (React reorders DOM node)
  • Key 'b': Bob stays Bob (React reorders DOM node)

The second approach is much more efficient and preserves component state.


Example: Input State Lost

function ItemList({ items }) {
  return (
    <div>
      {items.map((item, index) => (
        <div key={index}>
          <input defaultValue={item.name} />
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Problem: If you add an item to the beginning of the list:

  1. React thinks index 0 is the same element
  2. The input keeps its old value (doesn't reset)
  3. But the defaultValue prop changes (confusing state)

Fix: Use a unique key:

<div key={item.id}>
  <input defaultValue={item.name} />
</div>
Enter fullscreen mode Exit fullscreen mode

Part 5: Optimizing Reconciliation

1. Avoid Changing Component Types

Bad:

function App({ isLoggedIn }) {
  return isLoggedIn ? <UserDashboard /> : <LoginForm />;
}
Enter fullscreen mode Exit fullscreen mode

Why it's bad: Switching between completely different components causes full unmount/remount.

Better (if components share logic):

function App({ isLoggedIn }) {
  return <Dashboard isLoggedIn={isLoggedIn} />;
}

function Dashboard({ isLoggedIn }) {
  return isLoggedIn ? <UserView /> : <LoginView />;
}
Enter fullscreen mode Exit fullscreen mode

But: If they're truly different, the first approach is fine! The point is to avoid unnecessary type changes.


2. Stable Component Structure

Bad:

function App() {
  const [showHeader, setShowHeader] = useState(true);

  return (
    <div>
      {showHeader && <Header />}
      <Content />
      <Footer />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Issue: When showHeader toggles, React has to diff the entire tree differently.

Better:

function App() {
  const [showHeader, setShowHeader] = useState(true);

  return (
    <div>
      <Header visible={showHeader} />
      <Content />
      <Footer />
    </div>
  );
}

function Header({ visible }) {
  if (!visible) return null;
  return <header>Header</header>;
}
Enter fullscreen mode Exit fullscreen mode

Result: Component tree structure remains stable, making diffing faster.


3. Memoization with React.memo

Prevent unnecessary re-renders of child components:

const ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) {
  // Complex rendering logic
  return <div>{data}</div>;
});

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

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ExpensiveComponent data={data} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Result: ExpensiveComponent doesn't re-render when count changes because its props (data) haven't changed.


4. Use Keys Correctly in Dynamic Lists

Rules:

  • Use unique, stable IDs (key={item.id})
  • Don't use array index when items can be reordered/added/removed
  • Don't use random values (key={Math.random()}) — causes remount every render!
  • Use index only if the list is static and never reordered

Part 6: Reconciliation in React 18+ (Concurrent Rendering)

React 18 introduced concurrent rendering, which changes how reconciliation works.

Time-Slicing

React can now pause reconciliation work to handle urgent updates (like user input):

import { useTransition } from 'react';

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

  const handleChange = (e) => {
    setQuery(e.target.value); // High priority (input value)

    startTransition(() => {
      // Low priority (expensive search)
      setResults(performExpensiveSearch(e.target.value));
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <div>Loading...</div>}
      <ResultsList results={results} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  • React renders the input update immediately
  • React pauses the expensive search rendering to keep the UI responsive
  • If another high-priority update comes in (more typing), React abandons the incomplete work and starts fresh

This is only possible because reconciliation is now interruptible.


Automatic Batching

React 18 batches all state updates, even in async code:

function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // Before React 18: 2 renders (async callback)
  // React 18+: 1 render (automatic batching)
}

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 18+: Still 1 render!
}, 1000);
Enter fullscreen mode Exit fullscreen mode

Result: Fewer reconciliation cycles = better performance.


Part 7: Common Reconciliation Pitfalls

Pitfall 1: Creating Components Inside Render

Bad:

function Parent() {
  const Child = () => <div>Child</div>; // New component every render!

  return <Child />;
}
Enter fullscreen mode Exit fullscreen mode

Why it's bad: React sees a new component type every render, causing full unmount/remount.

Fix:

const Child = () => <div>Child</div>; // Defined outside

function Parent() {
  return <Child />;
}
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Spreading Props Without Memoization

Bad:

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

  const config = { theme: 'dark', lang: 'en' }; // New object every render!

  return <Child config={config} />;
}

const Child = React.memo(({ config }) => {
  // Re-renders every time Parent renders (even with React.memo!)
  return <div>{config.theme}</div>;
});
Enter fullscreen mode Exit fullscreen mode

Fix:

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

  const config = useMemo(() => ({ theme: 'dark', lang: 'en' }), []); // Stable reference

  return <Child config={config} />;
}
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Conditional Rendering with Different Keys

Bad:

function App({ isEditMode }) {
  return isEditMode ? <Form key="edit" /> : <Form key="view" />;
}
Enter fullscreen mode Exit fullscreen mode

Result: React sees different keys, so it unmounts and remounts the component (losing state).

Fix: Only change key when you want to reset state.


Quick Reference Cheat Sheet

Concept Explanation Best Practice
Virtual DOM Lightweight JS representation of DOM React handles it automatically
Reconciliation Process of comparing Virtual DOM trees Keep component structure stable
Diffing Algorithm to find differences Avoid changing component types
key prop Helps React identify list items Use unique IDs, not array index
React.memo Prevents re-renders if props unchanged Use for expensive components
useMemo Memoizes values to stabilize references Use for objects/arrays passed as props

Key Takeaways

React uses the Virtual DOM to batch DOM updates efficiently
Reconciliation compares old and new Virtual DOM trees to determine what changed
The diffing algorithm assumes different element types produce different trees (destroys and rebuilds)
key prop is essential for efficient list reconciliation — use unique IDs, not array index
Using index as key can cause bugs when items are added/removed/reordered
React.memo prevents unnecessary re-renders of child components
React 18+ supports concurrent rendering, making reconciliation interruptible
Keep component structure stable to make diffing more efficient


Interview Tip

When asked about the Virtual DOM and reconciliation:

  1. "The Virtual DOM is a JavaScript representation of the UI that React uses to minimize expensive DOM updates"
  2. Explain the process: "React compares the old and new Virtual DOM (diffing), calculates the minimum changes needed, and applies them to the real DOM in a batch"
  3. Explain key: "The key prop helps React identify which items in a list have changed, been added, or removed, making reconciliation more efficient"
  4. Common mistake: "Using array index as key can cause bugs when items are reordered because React can't track which item is which"
  5. Optimization: "Use React.memo to prevent unnecessary child re-renders and useMemo to stabilize object references passed as props"

Now go forth and never forget to add unique keys to your lists again!

Top comments (0)