Skip to content
Codeloom
React

React Virtual DOM and Reconciliation: How Rendering Actually Works

Understand React's virtual DOM, the reconciliation algorithm, fiber architecture, key-based diffing, and how React decides what to update in the real DOM.

·7 min read · By Codeloom
Advanced 12 min read

What you'll learn

  • What the virtual DOM is and why React uses it
  • How the diffing algorithm compares element trees
  • The role of keys in list reconciliation
  • How the Fiber architecture enables concurrent rendering
  • Practical implications for writing performant components

Prerequisites

None — this post is self-contained.

React developers interact with a declarative API: describe what the UI should look like, and React figures out how to update the DOM. The machinery behind that process is reconciliation. Understanding how it works helps you write faster components and debug mysterious re-render behavior.

The virtual DOM in 60 seconds

The virtual DOM is a plain JavaScript object tree that mirrors the structure of the real DOM. When you write JSX, React creates these objects:

// This JSX:
<div className="card">
  <h2>Title</h2>
  <p>Description</p>
</div>

// Becomes this object (simplified):
{
  type: 'div',
  props: {
    className: 'card',
    children: [
      { type: 'h2', props: { children: 'Title' } },
      { type: 'p', props: { children: 'Description' } }
    ]
  }
}

When state changes, React creates a new virtual DOM tree, compares it to the previous one, and applies only the differences to the real DOM. This comparison process is reconciliation.

Why not just update the DOM directly

DOM operations are not inherently slow. What is slow is the browser work that follows: recalculating styles, recomputing layout, and repainting pixels. Batching DOM updates minimizes this work.

React’s virtual DOM serves as a batching layer. Instead of making ten individual DOM mutations that trigger ten style recalculations, React computes the minimal set of changes and applies them in one batch. The result is fewer layout recalculations and fewer repaints.

The diffing algorithm

Comparing two arbitrary trees is an O(n^3) problem. React uses two heuristics to reduce this to O(n):

Heuristic 1: Different types produce different trees. If an element changes from <div> to <span>, React destroys the old subtree entirely and builds a new one. It does not try to reuse children.

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

// After
<span><Counter /></span>

// React destroys <Counter> and creates a new instance.
// All state inside Counter is lost.

Heuristic 2: Keys identify elements across renders. When React encounters a list, it uses keys to match elements between the old and new lists.

Without keys, React compares elements by position. This leads to unnecessary DOM mutations and lost state when items are reordered.

How element comparison works

React walks both trees simultaneously, comparing nodes at each position.

Same type, same position: React keeps the DOM node and updates only the changed attributes.

// Before
<div className="old" style={{ color: 'red' }} />

// After
<div className="new" style={{ color: 'blue' }} />

// React updates className and style.color on the existing DOM node.

Same type, component: React keeps the component instance, updates props, and re-renders it. State is preserved.

// Before
<UserProfile name="Alice" />

// After
<UserProfile name="Bob" />

// Same component instance. React updates the name prop and re-renders.
// Internal state (useState, useRef) is preserved.

Different type: React unmounts the old component and mounts a new one. All state is destroyed.

// Before
<TextInput />

// After
<TextArea />

// Different type. TextInput is unmounted (state lost). TextArea is mounted fresh.

List reconciliation and keys

Lists are where reconciliation gets interesting. Without keys, React matches by index:

// Before: [A, B, C]
// After:  [X, A, B, C]

// Without keys, React thinks:
// Position 0: A -> X (update)
// Position 1: B -> A (update)
// Position 2: C -> B (update)
// Position 3: (new) -> C (insert)
// Result: four DOM operations

With keys, React matches by identity:

// Before: [A(key=a), B(key=b), C(key=c)]
// After:  [X(key=x), A(key=a), B(key=b), C(key=c)]

// With keys, React sees:
// key=x: new (insert)
// key=a: moved (reorder)
// key=b: moved (reorder)
// key=c: moved (reorder)
// Result: one insert, existing nodes are reused

This is why using array indices as keys causes problems when items are reordered or removed. The index changes, so React treats the element as if it changed type, destroying and recreating state.

// Bad: index keys cause state loss on reorder
{items.map((item, index) => (
  <TodoItem key={index} item={item} />
))}

// Good: stable keys preserve state
{items.map((item) => (
  <TodoItem key={item.id} item={item} />
))}

The Fiber architecture

React’s original reconciler (called the “stack reconciler”) processed the entire tree synchronously. Once it started, it could not stop until the work was done. For large trees, this blocked the main thread and caused jank.

The Fiber architecture, introduced in React 16, replaced the stack reconciler with an interruptible one. Each node in the virtual DOM becomes a “fiber,” a JavaScript object that tracks the component, its state, and its place in the tree.

A fiber looks roughly like this:

{
  type: UserProfile,
  key: null,
  stateNode: <the DOM node or component instance>,
  child: <first child fiber>,
  sibling: <next sibling fiber>,
  return: <parent fiber>,
  pendingProps: { name: 'Alice' },
  memoizedState: <hook state linked list>,
  effectTag: 'UPDATE',
}

The fiber tree is a linked list, not a recursive tree. This lets React process one fiber at a time, yield control to the browser for high-priority work (like responding to user input), and resume where it left off.

Two-phase rendering

Fiber splits rendering into two phases:

Render phase (interruptible). React walks the fiber tree, computes the new output, and marks fibers that need DOM updates. This phase has no side effects. React can pause, abort, or restart it.

Commit phase (synchronous). React applies all the marked changes to the real DOM in one synchronous pass. This phase cannot be interrupted because partial DOM updates would leave the UI in an inconsistent state.

This two-phase design is what enables concurrent features. useTransition and startTransition tell React to process certain updates in a lower-priority render phase that can be interrupted by urgent updates.

Bailout optimization

React skips re-rendering a component when it determines that the output will not change. The conditions for a bailout are:

  1. The component type has not changed.
  2. The props are the same (referential equality for objects, value equality for primitives).
  3. The context values the component reads have not changed.
  4. No internal state has changed.

React.memo enforces condition 2 by wrapping the component in a comparison check. useMemo and useCallback help by preserving referential equality of objects and functions passed as props.

// Without memo: re-renders every time parent renders
function ExpensiveList({ items }: { items: Item[] }) {
  return <ul>{items.map(renderItem)}</ul>;
}

// With memo: skips re-render if items reference is the same
const ExpensiveList = React.memo(function ExpensiveList({ items }: { items: Item[] }) {
  return <ul>{items.map(renderItem)}</ul>;
});

Practical implications

Understanding reconciliation leads to concrete rules:

Keep component types stable. Do not define components inside render functions. Each render creates a new type, which forces React to unmount and remount, destroying all state.

// Bad: new component type every render
function Parent() {
  function Child() { return <div>child</div>; }
  return <Child />;
}

// Good: stable component type
function Child() { return <div>child</div>; }
function Parent() {
  return <Child />;
}

Use keys intentionally. Keys are not just for suppressing warnings. They control identity. Use a key to force a component to reset its state by changing the key.

// Reset form state when switching users
<UserForm key={userId} user={user} />

Structure your tree to match update patterns. Components that update together should be close in the tree. Components that update independently should be in separate subtrees. This minimizes the portion of the tree React needs to diff.

Avoid deep nesting without reason. Each level adds a fiber to traverse. Flatter trees reconcile faster. This matters for lists with thousands of items.

Understanding these internals is not about micro-optimizing every component. It is about having a mental model that explains why React behaves the way it does, so you can make better decisions when performance matters.