Skip to main content
CodeOath
← All posts

React120 min total · 17 parts

React Fundamentals: Components, Hooks, and the Virtual DOM

Part 5 of 17 · ~5 min

The Virtual DOM and Reconciliation

The reviewer types one character into the filter box. setQuery runs, and React re-runs Dashboard, which re-runs FilterBar, CandidateTable, and a CandidateRow for every visible candidate. Eight hundred component functions, on every keystroke.

That should be catastrophic, and it is not, and the reason is the chapter.

Touching the real DOM is expensive — a write can invalidate layout, forcing the browser to recompute geometry and repaint. Running a JavaScript function that returns an object is not expensive. So React puts the cheap thing between you and the expensive thing:

  1. Re-run the component functions, producing a new tree of React elements describing what the screen should be.
  2. Compare it against the tree from last time. This step is called reconciliation.
  3. Apply only the real DOM operations the difference requires — often none at all.

That in-memory element tree is what people mean by "the virtual DOM". The name is informal and slightly misleading — React's internal structure is a tree of fibers, carrying more than the elements you returned — but the idea it points at is right: a cheap model of the page that can be rebuilt freely, with an expensive real page updated from the diff.

Which is why a re-render is not, by itself, a performance problem. Rebuilding eight hundred description objects costs very little. What costs is unnecessary writes to the real DOM, and diffing exists precisely to avoid them.

Comparing two arbitrary trees properly is a famously expensive problem. React does not do it properly — it uses a linear-time heuristic built on two assumptions, and both assumptions are things you can violate:

  • Two elements of different types produce different trees. So React does not attempt to diff across a type change.
  • The developer can say which children are stable across renders, using key.

Everything else in this chapter follows from those two.

Why keys matter

Our table is sortable. The reviewer is halfway through typing a note on the third row, clicks the Score column header to sort by score descending, and the row that was third is now seventh.

Here is that table written with the index as its key:

{candidates.map((candidate, index) => (
  <CandidateRow key={index} candidate={candidate} />   // do not do this
))}

The half-typed note stays in position three, now attached to a different person entirely. In a hiring tool that is not a visual glitch — it is a note reading "not strong enough on system design" sitting on the record of somebody it was not written about.

Why it happens: React matches children by key within a parent. With key={index}, the element at position three has key 3 before the sort and key 3 after it. React compares them, sees the same key and the same type, and concludes it is the same row that merely received new props. So it keeps the existing DOM node and the existing component instance — including the useState inside ReviewForm, including the browser's own state on that <textarea>, cursor position and all — and just updates the name and score around it.

Use the candidate's id and the problem evaporates:

{candidates.map((candidate) => (
  <CandidateRow key={candidate.id} candidate={candidate} />
))}

Now the row that moved from position three to position seven carries key c_8814 to its new position, and React moves the existing DOM node there with its state intact, because that is what identity means.

A key needs to be stable across renders and unique among its siblings — not globally unique, and not anything derived from the render (Math.random() as a key is worse than an index, because it guarantees a fresh mount every single render). A database ID is as good as it gets. An index is acceptable in exactly one case: a list that is never reordered, never filtered, and never has items inserted or removed anywhere but the end. That describes fewer lists than people assume, and it stops describing yours the day somebody adds sorting.

Reconciliation rules, briefly

  • Swap the element type at a given spot and React wipes the subtree. <div> turning into <span>, or <TableView /> turning into <CardView /> — React makes no attempt to reconcile across that boundary; it just discards everything underneath, every DOM node and every scrap of component state, and mounts a replacement from nothing.
  • Leave the type untouched, though, and React reuses what's already sitting there — the existing DOM node stays exactly where it is, and only the handful of attributes that genuinely differ get patched in. Components work the same way: a matching type at a matching spot reads as the same running instance, so its state and its hooks carry straight through.
  • A component only holds on to its state across a re-render when three things all stay fixed at once: its slot in the tree, its type, and its key. Lose any one of the three and React throws away the old instance and starts over.

That last rule is one sentence with two faces. It is the bug in the section above — position-based matching leaking state between list items — and it is the tool from the last chapter, where changing key={candidateId} deliberately destroys the review form so a fresh one mounts. Same mechanism, used on purpose.

There is one more consequence worth knowing, because it produces bugs that look supernatural. Defining a component inside another component's body creates a brand-new function on every render:

function Dashboard() {
  function RowInner({ candidate }) { ... }   // a new function object every render
  return <RowInner candidate={c} />;
}

A new function is a new type. A new type means React tears the subtree down and remounts it — every render. Every piece of state inside it resets, every effect re-runs, and any input inside it loses focus mid-keystroke. Define components at module level, and pass data through props.