DeepFrontend
Practice ArenaBlogSign in
Go Pro
DeepFrontend

Interview-grade learning paths and hands-on practice for mid-to-senior engineers. Master internals, patterns, and system architecture with runnable code.

All systems operational

Core Courses

  • -JavaScript Internals
  • -React Reconciliation
  • -TypeScript rigor
  • -Next.js Caching
  • -Node.js Event Loop
  • -System Design
  • -Web Security
  • -CSS & Page Layouts

Explore

  • Learning Paths
  • Practice Arena
  • Pricing Options
  • HTML Sitemap

Resources

  • Privacy Policy
  • Terms of Service
  • Email Support

© 2026 DeepFrontend. All rights reserved.

Expert learning environments for web engineering teams.

Home/React/Reconciliation & the Virtual DOM
advanced30 min read·Updated Jun 2025

Reconciliation & the Virtual DOM

The Virtual DOM isn't a performance trick — it's a comparison mechanism. React diffs the new element tree against the last one it committed, and the ONE piece of information that diff runs on is each element's type plus its key. Nearly every list bug and every reconciliation interview question traces back to that single fact.

reconciliationvirtual-domkeysdiffingadvanced

Knowledge Check

4 questions · pass at 70%

0/4
easymcq

1. What are the TWO pieces of information React's reconciliation uses to decide update-vs-replace at a given tree position?


Interview Questions

2 questions

Cheatsheet

Download-ready reference
Cheatsheet
Reconciliation = the diffing algorithm between the new element tree
  and the last COMMITTED tree. Two rules, per tree position:

  SAME type at this position   → efficient UPDATE (props diffed,
                                   instance/DOM node REUSED)
  DIFFERENT type at this position → full UNMOUNT of old subtree +
                                   MOUNT of new one — no children
                                   diffing attempted at all

Lists: React matches items by KEY, not position.
  Same key in old + new render → same logical item (update/move).
  New key → mount. Missing key → unmount.

Index-as-key = position IS identity. Breaks the moment items are
  inserted/removed/reordered anywhere except pure append-at-end —
  DOM state (inputs, focus) can attach to the WRONG logical item.

key is NOT a prop — React strips it before constructing props.
  Need an identifier inside the component? Pass a separate prop.

Changing an element's TYPE at a given position (even a wrapping
  <div> → <span>) destroys everything below it, including
  components whose own code never changed.

Related topics

Rendering LifecycleProPerformance OptimizationPro

On this page

30m
Knowledge CheckInterview QuestionsCheatsheet

Concept#

The beginner framing: React keeps a lightweight in-memory copy of the UI — the "Virtual DOM" — and compares the new version against the old one to figure out the minimal set of real DOM changes needed, instead of rebuilding the whole page from scratch on every update.

The precise mental model: reconciliation is the diffing algorithm React runs between the element tree a render just produced and the tree it committed last time. Rebuilding real DOM nodes is expensive; comparing two in-memory object trees is cheap — so React does the comparison first, and only touches the real DOM for what actually changed. The entire algorithm rests on two rules, applied position by position down the tree:

  1. If the element type at a given position is the same (both a <div>, both a Counter), React keeps the existing DOM node/component instance and just updates its props — an efficient, in-place update.
  2. If the element type at a given position differs (a <div> became a <span>, or Counter became Timer), React doesn't try to reconcile them at all — it tears down the entire old subtree and mounts a completely new one, no matter how similar the two might look.
// Render 1:
<div className="box"><Counter /></div>
 
// Render 2 — SAME type at this position (div, then Counter):
<div className="highlighted"><Counter /></div>
// → efficient UPDATE: className changes on the existing DOM node,
//   Counter's existing instance (and its state) is preserved
 
// Render 2 (alternative) — DIFFERENT type at this position:
<span className="box"><Counter /></span

Keys: how React reconciles lists#

For a single element, "same position in the tree" is enough to decide same-vs-different. For a list of elements, position alone isn't reliable — items get added, removed, and reordered — so React needs an explicit, stable identity for each item: the key prop.

{items.map((item) => (
  <Row key={item.id} text={item.text} /> // stable id, NOT the array index
))}

React matches list items by key, not by position: an item whose key appears in both the old and new render is treated as the same item (updated in place, possibly moved), regardless of where it sits in the array. An item whose key is new gets mounted; an item whose key disappeared gets unmounted.

Changing key destroys and recreates a component — its state is lost
live — not a recording
Parentrendered 1×
key = 0 · label = "A"
Arendered 1×
internal clicks: 0

Click "+1 click" a few times to build up StatefulBox's own internal count. Then click "Update label prop (same key)" — the label changes but the click count survives, since it's the same component instance, just re-rendered with a new prop. Now click "Change key (forces remount)" — StatefulBox is destroyed and a brand-new instance is mounted from scratch, and the click count resets to 0, even though the label may be identical to before.

Index-as-key: why it silently breaks#

Using the array index as key is really saying "position IS identity" — which is exactly the assumption that breaks the moment items are inserted, removed, or reordered anywhere except the very end of the list.

Index-as-key + prepend = every row mutates in place
step 1 / 2
items.map((item, i) => (
<Row key={i} text={item} /> // ❌ index as key
))
Before

(nothing yet)

After
key: 0untouched
Apples
input: "yum"
key: 1untouched
Bananas
key: 2untouched
Cherries

Initial render. Each Row is keyed by its INDEX. Row 0 ("Apples") has an uncontrolled input where the user typed "yum" — that input's value lives in the real DOM node, attached to key="0".

Stable keys + prepend = one clean insert, everything else untouched
step 1 / 2
items.map((item) => (
<Row key={item.id} text={item.text} /> // ✅ stable, unique id
))
Before

(nothing yet)

After
key: applesuntouched
Apples
input: "yum"
key: bananasuntouched
Bananas
key: cherriesuntouched
Cherries

Initial render. Each Row is keyed by a STABLE id that travels with the item's actual data, not its position in the array.

Type changes tear down the whole subtree — regardless of the children#

Changing element type at the same position tears down the whole subtree
step 1 / 2
function Panel({ isHighlighted }) {
return (
<div className="box">
<Counter />
</div>
);
}
Before

(nothing yet)

After
key: div.boxuntouched
<div className="box">
key: Counteruntouched
<Counter /> — internal count = 5

The user has clicked Counter's internal +1 button five times — count = 5, living in Counter's own state, nested inside a <div>.


Try It#

Predict what happens before checking the solution.

function Panel({ variant }) {
  return variant === "compact"
    ? <div><Content /></div>
    : <section><Content /></section>;
}

If Panel re-renders with variant toggling between "compact" and anything else, does Content keep its internal state across that toggle?

Solution

No — Content loses all its internal state every time variant toggles. Even though Content itself is completely unchanged, its parent element at that tree position switches between <div> and <section> — a type change — so React tears down the entire subtree (the wrapper element AND Content's instance inside it) and mounts a fresh one each time. The fix, if Content's state should survive the toggle, is to keep the wrapper element type constant and vary only its props/class, or to restructure so Content isn't nested inside the element that's changing type.


Implement It Yourself#

Build a simplified reconciler that implements the two core rules — same-type-updates, different-type-tears-down — directly:

function reconcile(oldNode, newNode) {
  if (oldNode === null) {
    return { op: "mount", node: newNode };
  }
  if (oldNode.type !== newNode.type) {
    // type differs — no attempt to diff children AT ALL, full replace
    return { op: "unmount+mount", old: oldNode, node: newNode };
  }
  // same type — diff props, keep the same underlying instance
  const changedProps = Object.keys(newNode.props).















This is the exact shape of React's real algorithm at the single-element level — the moment oldNode.type !== newNode.type, there's no attempt made to salvage anything from the old subtree, which is precisely why a type change is so much more expensive (and so much more state-destructive) than a prop change.


Under the Hood#

Comparing two trees by type-and-position rather than doing a full, general tree-diff (which is computationally expensive — years of computer science research puts general tree diffing at roughly O(n³)) is a deliberate, pragmatic trade React makes: assume that elements of a different type at the same position are rarely meaningfully related, and get an O(n) algorithm in exchange. This mirrors the same identity-vs-equality distinction from Data Structures and object reference comparison in JavaScript generally — React's type !== type check is just a === reference/value comparison, the cheapest operation available, applied as the very first gate before any deeper work happens.


Common Mistakes#

1. Using the array index as key for a list that can reorder, insert, or remove#

Covered above in depth — index-as-key silently attaches state and DOM identity to a position, not to the actual data, which corrupts as soon as the list order changes for any reason other than pure appending at the end.

2. Wrapping content in a conditionally-different element type just for styling#

{isBig ? <div className="big"><Widget /></div> : <span className="small"><Widget /></span>}

This destroys and recreates Widget every time isBig toggles, even though the intent was purely cosmetic. Keep the wrapper type constant and vary only its props/class when the children's state should survive.

3. Assuming key is a regular prop accessible inside the component#

function Row({ key, text }) { // ❌ key is NEVER passed as a prop
  console.log(key); // always undefined
}

key is consumed entirely by React's reconciliation process before the component ever runs — it's not part of props at all. If a component needs its own identifier for logic inside itself, pass it under a different prop name (id, not key).


Best Practices#

  • Always use a stable, unique identifier as key for list items — a database id, a UUID, anything intrinsic to the data — never the array index unless the list is truly static (never reordered, filtered, or prepended to).
  • Keep a subtree's wrapper element type stable across renders if the content inside needs to preserve its state — vary props and classes, not the tag or component itself, purely for styling changes.
  • Remember key is metadata for React, not a prop for your component — pass a separate prop if the component itself needs that value.

Performance Tips#

  • Reconciliation's type-and-key-based matching is what makes updating a long list cheap: with correct keys, inserting one item at the front of a 10,000-item list is a single mount operation, not 10,000 updates — exactly what list-with-keys demonstrates above.
  • A type change (Common Mistake #2) is far more expensive than a prop change specifically because it skips diffing entirely and does a full unmount/remount of the whole subtree — profile any surprisingly slow update for a hidden type change at some tree position, not just "too many re-renders."

>
// → full UNMOUNT of the old subtree + MOUNT of a new one,
// even though Counter itself "looks the same" both times
filter
(
(key) => newNode.props[key] !== oldNode.props[key]
);
return { op: "update", changedProps, node: newNode };
}
reconcile(
{ type: "div", props: { className: "box" } },
{ type: "div", props: { className: "highlighted" } }
);
// { op: "update", changedProps: ["className"], ... } — cheap, in-place
reconcile(
{ type: "div", props: { className: "box" } },
{ type: "span", props: { className: "box" } }
);
// { op: "unmount+mount", ... } — full replace, no props diff even attempted