Reconciliation, diffing & why React is fast

When I was studying React, I kept hearing words like “virtual DOM,” “reconciliation,” and “keys,” but I didn’t really understand them. After building a few components and checking how my app performed, it finally clicked — these ideas actually help make React apps faster. Here’s a simple, practical explanation I’d share with someone in the same spot as me.

What reconciliation actually is

Every time my React component’s state or props change, React doesn’t immediately touch the real DOM. Instead, React:

  1. Builds a new Virtual DOM tree (a lightweight JS object representation of what the UI should look like).

  2. Diffs that new tree against the previous virtual DOM tree to detect changes.

  3. Patches the real DOM — but only where the diff shows something actually changed.

So reconciliation = the process React uses to compute the diff and apply the minimal set of updates to the real DOM.

Key ideas of the diffing algorithm (the strategies that make it fast)

React’s diff algorithm is not a full, expensive tree-to-tree comparison. It uses several smart rules:

  • Element type matters. If a node changes from <div> to <span>, React replaces it rather than trying to reuse it.

  • Keys for lists. When we render arrays, React uses the key prop to match items between renders. Good keys let React reuse DOM nodes and only update what moved/changed.

  • Top-down short-circuit. React walks the tree top-down. If a subtree hasn’t changed (same props, same type), React skips diffing inside it.

  • O(n) list comparison with keys. If keys exist, React can more efficiently detect inserts/removes instead of doing an expensive full comparison.

This set of rules gives React an efficient average complexity — it avoids the worst-case brute-force diff for most real apps.

Example: why keys matter (bad vs good)

Bad (using index as key — leads to wasted updates when items reorder):

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((t, i) => (
        <li key={i}>{t.text}</li> // ❌ index key
      ))}
    </ul>
  );
}

Good (stable unique id — let React preserve identity):

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map(t => (
        <li key={t.id}>{t.text}</li> // ✅ stable key
      ))}
    </ul>
  );
}

If items reorder and you use index as the key, React will treat items as new, re-create DOM nodes and re-run renders — which is inefficient and can break things like input focus or animations.

Preventing unnecessary work: memo, useMemo, useCallback

Reconciliation can skip subtrees if React can tell they didn’t change. I use these patterns when a component is expensive to render.

const HeavyItem = React.memo(function HeavyItem({ data }) {
  // expensive render logic
  return <div>{data.name}</div>;
});

// parent
function List({ items }) {
  const handleClick = useCallback(id => { /* ... */ }, []);
  return items.map(i => <HeavyItem key={i.id} data={i} onClick={handleClick} />);
}

React.memo prevents re-render when props are shallowly equal. useCallback / useMemo help avoid creating new function/object props each render, which would otherwise defeat memo.

Practical efficiency differences I noticed while studying

  • Naive approach: lifting all state to a top-level and passing props down caused many components to re-render on small changes. The DevTools profiler showed high commit times.

  • After optimisations: splitting state, memoizing expensive child components, and fixing list keys dropped re-render counts significantly. The UI felt snappier and the profiler flame charts showed much smaller commit times.

For large lists, I learned to use virtualisation (e.g. react-window) — this avoids rendering off-screen rows entirely, which is a different but complementary optimisation to reconciliation.

Try it yourself: measuring React performance

If you want to see how reconciliation and re-renders work in your own app, you can try this with React DevTools:

  1. Open your app in the browser.

  2. Open React DevTools (in Chrome or Firefox).

  3. Go to the Profiler tab.

  4. Click the Record button.

  5. Do something in your app (like clicking “add,” typing in an input, or reordering a list).

  6. Stop recording — you’ll see a flame chart showing which components re-rendered and how long it took.

👉 Try it once before optimising (e.g., with index keys, no memo) and once after optimising (e.g., with proper keys or React.memo). You’ll notice fewer renders and shorter commit times.

Quick checklist to remember

  • Use stable keys ( id, not index) for lists.

  • Split state so only components that need updates receive the changing state.

  • Wrap expensive components with React.memo.

  • Avoid creating new objects/functions inline unless necessary — use useCallback / useMemo.

  • Profile before optimising — measure, change, measure again.

  • For huge lists, use virtualisation (react-window) rather than trying to micro-optimise reconciliation.

Final thought

React is fast because it minimises real DOM mutations via reconciliation and a smart, heuristic diffing approach. As a beginner I learned the most by profiling: most “slowness” comes from unnecessary re-renders (bad keys, state in the wrong place, un-memoized heavy children) — and those are fixable with small changes.

0
Subscribe to my newsletter

Read articles from Leonardo Huguenin Pereira directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Leonardo Huguenin Pereira
Leonardo Huguenin Pereira