Skip to main content
paulund

5 min read

#react#performance#fundamentals

The Virtual DOM and How React Reconciliation Works

When I first heard "virtual DOM," I pictured something mystical: a shadow copy of the page living in memory, syncing with the real DOM like some kind of mirror. That's... not quite right, and the fuzzy mental model caused me to misread performance problems for a long time.

Here's what's actually happening, and why it matters for how you write React components.

What the virtual DOM is

The virtual DOM is a plain JavaScript object tree. When you write JSX, React turns it into a description of the UI, not actual DOM nodes. Something like:

<div className="card">
  <h2>{title}</h2>
  <p>{body}</p>
</div>

becomes roughly:

{
  type: 'div',
  props: { className: 'card' },
  children: [
    { type: 'h2', props: {}, children: [title] },
    { type: 'p', props: {}, children: [body] }
  ]
}

This object is cheap to create and cheap to compare. Touching real DOM nodes is expensive: reading layout properties, triggering repaints, forcing reflows. React delays that work until it knows exactly what changed.

How reconciliation works

When state or props change, React calls your component function again and gets a new virtual DOM tree. It then compares the new tree against the previous one. This comparison process is reconciliation.

React walks both trees in parallel. For each node it asks: is this the same type as before?

If yes, React reuses the existing DOM node and only updates the attributes that changed.

If no, React tears out the old node and builds a new one from scratch.

This is why swapping component types is expensive. If you render a <div> on one render and a <section> on the next, React destroys everything inside the <div> and creates fresh nodes, even if the content looks identical.

// React destroys and recreates the inner tree when condition flips
function Panel({ isSpecial }: { isSpecial: boolean }) {
  if (isSpecial) {
    return <section><Content /></section>
  }
  return <div><Content /></div>
}

If both branches used <div>, React would keep the DOM node and just update whatever changed inside it.

Where keys come in

Reconciliation gets trickier with lists. React has to figure out which item in the new list corresponds to which item in the old list.

By default it matches by position. Item 0 matches item 0, item 1 matches item 1. That works fine if items only change in place. It breaks badly when items are added at the start, reordered, or removed from the middle.

// React compares by position — adding at the front causes every item to re-render
{items.map((item) => (
  <TodoItem>{item.text}</TodoItem>
))}

The key prop gives React a stable identity for each item:

{items.map((item) => (
  <TodoItem key={item.id}>{item.text}</TodoItem>
))}

Now React can match key="42" in the new list to key="42" in the old list, regardless of position. Only genuinely new or changed items get touched.

Using array index as a key is usually wrong for dynamic lists. It tells React "compare by position," which is exactly what you were trying to avoid.

What actually triggers a render

A common misconception: the virtual DOM comparison happens instead of re-rendering. It doesn't. Reconciliation happens after a component function runs. React renders first, then diffs.

Components re-render when:

  • Their own state changes (via useState or useReducer)
  • Their parent re-renders and passes new props
  • A context value they subscribe to changes

A re-render means the component function runs again. React gets a new virtual DOM subtree. Then reconciliation figures out whether the real DOM needs to change. If the output is identical, nothing in the browser changes, but the function still ran.

This is where tools like React.memo fit in. They let you skip re-running the component function at all, if props haven't changed, which skips both the render and the reconciliation for that subtree.

const Card = React.memo(function Card({ title, body }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <p>{body}</p>
    </div>
  )
})

But React.memo only helps if the props actually stay the same between renders. Passing a new object or function literal on every parent render defeats it, which is the problem useMemo and useCallback address.

The Fiber architecture (brief)

React's current reconciler is called Fiber. It replaced the original stack-based reconciler around React 16.

The key change: Fiber made reconciliation interruptible. The old reconciler would walk the whole tree synchronously. If that took too long, the browser couldn't respond to user input. Fiber breaks the work into small units and yields back to the browser between them.

This is what enables features like Concurrent Mode, transitions, and Suspense. React can pause a low-priority render, handle a high-priority update (like a user typing), then resume.

You don't interact with Fiber directly, but it explains why React can prioritize urgent updates over expensive ones.

What changes in practice

Understanding reconciliation makes a few things click.

Don't define components inside render functions. Every render creates a new function reference, so React sees a different type each time and destroys the subtree.

// Bad — a new component type on every render
function Parent() {
  const Child = () => <div>hello</div> // defined inside
  return <Child />
}

// Good — defined outside
const Child = () => <div>hello</div>

function Parent() {
  return <Child />
}

Use stable IDs from your data as keys, not array index. Keys are also useful outside lists. You can force React to reset a component entirely by changing its key.

// Changing the key forces a full remount — useful for resetting form state
<ProfileForm key={userId} userId={userId} />

If a parent re-renders frequently, push state down or lift expensive components up where they won't be caught in the re-render cycle.

The mental model that stuck

I think of it this way: React's job is to keep the DOM in sync with a description of the UI. The virtual DOM is that description. Reconciliation is the diff. The actual DOM update is the patch.

React is doing the same thing you'd do manually: figure out the minimum set of DOM mutations needed to go from state A to state B, just automatically and fast enough that you usually don't need to think about it.

When you do need to think about it, the questions are: which components are re-running unnecessarily, and which DOM nodes are being destroyed and recreated when they could be reused?

Related notes

  • When to Use useMemo and useCallback

    The cost of memoisation is not zero. A practical guide to when useMemo and useCallback actually help...

  • API Caching

    How to use Cache-Control headers and ETags in your REST API to reduce bandwidth, lower server load, ...

  • Avoiding N+1 Queries in Laravel

    Learn what the N+1 query problem is, why it silently degrades Laravel application performance, and h...


Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.