Reconciliation and the Virtual DOM

Every time state or props change in a React app, React has to figure out what actually needs to update in the real DOM. Rather than tearing down and rebuilding the page on every change, React builds a lightweight in-memory description of the UI called the Virtual DOM, compares it to the previous version using a process called reconciliation, and applies only the minimal set of real DOM changes needed. Understanding this process is the key to writing fast React apps and avoiding subtle bugs where components lose state or re-render more than expected.

Overview: What Reconciliation and the Virtual DOM Actually Are

The Virtual DOM is not a copy of the browser’s DOM API — it is a plain JavaScript object tree. Every time a component renders, it returns a tree of React elements (the objects created by JSX, like { type: 'div', props: { className: 'box', children: [...] } }). React keeps this tree in memory instead of touching the browser immediately.

When state changes (via a useState setter, a parent re-render, or a context update), React does not immediately mutate the screen. Instead it re-runs the affected component functions to produce a new Virtual DOM tree, then compares that new tree to the previous one. This comparison step is reconciliation: React’s algorithm for deciding, node by node, what changed. Only the differences (called a diff) are translated into real DOM operations — inserting a node, updating an attribute, removing a node — in a phase called the commit. This is why React is described as following a render → reconcile → commit cycle.

Comparing full trees naively would be O(n³) for n nodes, far too slow for interactive UIs. React makes this fast with a heuristic diffing algorithm built on two assumptions that hold true for almost all UI code:

  • Different element types produce different trees. If the root element type at a given position changes (say, a <div> becomes a <section>, or a <Timer /> becomes a <Clock />), React does not bother diffing their children. It tears down the old subtree completely (unmounting all components inside it, running cleanup functions) and builds a brand-new subtree from scratch.
  • Elements of the same type at the same position are assumed to represent the same UI “thing” across renders, so React reuses the existing DOM node and component instance, only updating the props/attributes that changed, and keeping local state (hooks) intact.

Internally, modern React (since React 16) implements this with an architecture called Fiber. Each component instance gets a corresponding “fiber” — a JavaScript object that tracks its type, props, state, and a linked list of its hooks in call order. This is precisely why the Rules of Hooks exist: React matches up hooks between renders purely by their position in that call order, not by name. If a component calls a different number or order of hooks on one render versus the next, React’s bookkeeping for that fiber gets corrupted.

For lists specifically (arrays rendered with .map()), React cannot rely on position alone, because items can be reordered, inserted, or removed. This is where the key prop comes in: it gives React a stable identity for each item so it can match old and new elements correctly across a reorder, instead of assuming “whatever is now at index 2 is the same thing that was at index 2 before.”

Syntax: Writing Reconciliation-Friendly JSX

There is no special API you “call” to trigger reconciliation — it happens automatically whenever a component re-renders. The syntax that matters is how you structure JSX so React’s diffing algorithm can do its job correctly.

Pattern Why it matters for reconciliation
key={stableId} on list items Lets React match array items across renders by identity, not position, preserving state and DOM nodes correctly during reorders.
Same element type at the same JSX position React reuses the DOM node/component instance and only patches changed props instead of unmounting and remounting.
Consistent hook call order React matches hooks to a fiber’s internal hook list by call order; conditional hooks break this mapping.
<Fragment> / <></> instead of extra wrapper elements Avoids changing the element type at a position just to group children, which would otherwise force needless remounts.

Examples

Example 1: Stable keys preserve per-item state across a reorder

import { useState } from "react";

function TodoItem({ text }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <li onClick={() => setExpanded(!expanded)}>
      {text} {expanded ? "(expanded)" : ""}
    </li>
  );
}

export default function TodoList() {
  const [todos, setTodos] = useState([
    { id: "a1", text: "Buy milk" },
    { id: "a2", text: "Walk dog" },
    { id: "a3", text: "Write code" },
  ]);

  function reverseOrder() {
    setTodos([...todos].reverse());
  }

  return (
    <div>
      <button onClick={reverseOrder}>Reverse order</button>
      <ul>
        {todos.map((todo) => (
          <TodoItem key={todo.id} text={todo.text} />
        ))}
      </ul>
    </div>
  );
}

This renders a button and three list items. Clicking an item toggles its own expanded flag. Because each TodoItem uses key={todo.id}, that key is tied to the todo’s identity, not its position. Clicking “Buy milk” to expand it, then clicking “Reverse order,” still shows “Buy milk” expanded — React matched the old fiber to the same id wherever it moved, so its expanded state traveled with it.

Example 2: Using the array index as a key breaks that guarantee

{todos.map((todo, index) => (
  <TodoItem key={index} text={todo.text} />
))}

This looks harmless but is a common bug source. With key={index}, React identifies items by their position in the array, not by what they represent. After calling reverseOrder(), the item that is now first (previously last) reuses the fiber that used to belong to index 0 — so the expanded flag stays attached to the top row of the list instead of following “Buy milk.” The visible text updates correctly (props are patched), but the local expanded state is now wrong for that item. Index keys are only safe for lists that never reorder, filter, or have items inserted/removed from anywhere but the end.

Example 3: Changing element type forces a full unmount and remount

import { useState, useEffect } from "react";

function Timer() {
  useEffect(() => {
    console.log("Timer mounted");
    return () => console.log("Timer unmounted");
  }, []);
  return <p>I am a timer.</p>;
}

export default function Toggle() {
  const [showAsDiv, setShowAsDiv] = useState(false);

  return (
    <div>
      <button onClick={() => setShowAsDiv(!showAsDiv)}>
        Toggle wrapper
      </button>
      {showAsDiv ? (
        <div>
          <Timer />
        </div>
      ) : (
        <section>
          <Timer />
        </section>
      )}
    </div>
  );
}

The wrapper around Timer alternates between <div> and <section> on every click. Because the element type at that position changes, React’s diffing algorithm does not try to reconcile the subtrees — it discards the entire old subtree (running Timer‘s cleanup function) and mounts a completely new one. If both branches used the same wrapper type (say, always <div>), Timer would never unmount, and its useEffect with an empty dependency array would only log “Timer mounted” once.

Output: Timer mounted logs once on first render. Each subsequent click logs Timer unmounted immediately followed by Timer mounted, because the wrapper type flip forces React to destroy and rebuild the subtree every time.

How It Works Step by Step: Mount, Update, Unmount

On mount: React calls the component function, gets back a tree of elements, builds fibers for each element, and commits the entire tree to the real DOM by creating actual nodes. Effects (useEffect) run after the commit, in a separate pass, so the DOM is guaranteed to exist first.

On a state update: Calling a state setter schedules a re-render. React re-runs the component function (and any child components whose props changed as a result) to produce a new element tree. It walks the new tree alongside the previous fiber tree, position by position: same type at a position → reuse the fiber, diff and patch only changed props/attributes; different type → discard the old subtree and mount fresh. For list children, this position-by-position walk is guided by key instead of raw index. Once the diff is computed, React commits only the necessary DOM mutations in one batch, then runs any effects whose dependencies changed (cleaning up the previous effect first if present).

On unmount: When a component’s subtree is removed (its parent stops rendering it, or its position’s element type changed), React runs each of its effects’ cleanup functions, then detaches the DOM nodes and discards the fiber.

Common Mistakes

Mistake 1: Missing or unstable keys in a list. Omitting key entirely makes React fall back to index-based matching and log a console warning. Worse, using something non-unique or regenerated every render (like key={Math.random()}) forces React to treat every item as brand new on every render, unmounting and remounting the whole list constantly.

{/* Missing key entirely — React warns and falls back to index matching */}
{todos.map((todo) => (
  <TodoItem text={todo.text} />
))}

Fix: always use a stable, unique identifier from your data, such as a database id, not the array index, for lists that can reorder or change.

{todos.map((todo) => (
  <TodoItem key={todo.id} text={todo.text} />
))}

Mistake 2: Calling hooks conditionally. Because React matches hooks to a fiber by call order, wrapping a hook call in an if statement shifts every hook after it out of sync between renders.

function Profile({ user, showBio }) {
  if (showBio) {
    const [expanded, setExpanded] = useState(false); // BUG: conditional hook
  }
  return <p>{user.name}</p>;
}

When showBio flips between renders, React throws an error like “Rendered fewer hooks than expected,” because the linked list of hooks on the fiber no longer matches. Fix: always call the hook unconditionally at the top level, and use the condition only around what you do with its value.

function Profile({ user, showBio }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <p>
      {user.name}
      {showBio && (expanded ? user.bio : "...")}
    </p>
  );
}

Mistake 3: Not realizing a type change resets state. Developers are often surprised that toggling between two similar-looking branches (e.g., swapping <UserForm /> for <GuestForm /> at the same JSX position) wipes out all internal state in that subtree, including input values and scroll position. This is expected reconciliation behavior, not a bug — if you need to preserve identity across such a swap, keep the wrapper type constant and vary only what’s inside, or use a shared component with a prop controlling the mode.

Best Practices

  • Always give list items a key derived from stable, unique data (an id), never the array index, unless the list is static and never reorders.
  • Keep the same element/component type at a given JSX position across renders when you want to preserve state; only change type when you intend to reset that subtree.
  • Call hooks only at the top level of a component or custom hook — never inside conditions, loops, or nested functions.
  • Use a <Fragment> (<></>) instead of an unnecessary wrapper <div> so you don’t accidentally shift element types elsewhere in the tree.
  • Remember that reconciliation compares the new Virtual DOM tree to the previous one — it does not know or care what happened in the real DOM outside of React, so avoid manually mutating DOM nodes React controls.
  • If you deliberately want to reset a component’s state (for example, restart a form when switching users), pass a different key to force React to treat it as a new instance rather than fighting the diffing algorithm.

Practice Exercises

  • Build a list of comments rendered with key={comment.id}, each with a local “liked” boolean state. Add a “Sort by newest” button that reorders the array, and verify the liked state follows the correct comment. Then switch the key to the array index and observe the state now sticking to the wrong row.
  • Create a component that renders either a <div><Counter /></div> or a <span><Counter /></span> based on a toggle, where Counter has its own useState count. Click the counter to increment it, then toggle the wrapper and confirm the count resets to zero, logging mount/unmount messages from a useEffect to prove it.
  • Write a custom hook that conditionally calls useState inside an if block on purpose, use it in a component with a toggleable prop, and observe (in your head or by describing it) the “Rendered fewer hooks than expected” error React would throw. Then rewrite it correctly by moving the condition outside the hook call.

Summary

  • The Virtual DOM is an in-memory JavaScript tree describing the UI; reconciliation is the algorithm that diffs a new Virtual DOM tree against the previous one to find the minimal set of real DOM changes.
  • React’s cycle is render (call component functions) → reconcile (diff old vs. new trees) → commit (apply only the necessary DOM mutations, then run effects).
  • Same element type at the same position reuses the fiber and patches props; a different type triggers a full unmount of the old subtree and mount of a new one, including running effect cleanups.
  • The key prop gives list items a stable identity so React can correctly match, reorder, insert, and remove items instead of assuming index equals identity.
  • Hooks are matched to a component’s fiber by call order, which is why hooks must always run unconditionally at the top level, in the same order, every render.