Skip to content

6.4.1 — Reconciliation & Fiber

A list of editable rows. The user types "Lisbon" into the third input, then deletes the first row. The text "Lisbon" is now in the second input, attached to the wrong record.

tsx
{rows.map((row, i) => <AddressRow key={i} row={row} />)}   

Change one thing and it is fixed:

tsx
{rows.map((row) => <AddressRow key={row.id} row={row} />)}  

Nothing else changed. To understand why the first version corrupts data — not just the display, but state the user typed — you need to know what React does between "you returned some elements" and "the DOM changed", which is the subject of this page.

1. What React actually is

The whole model is one equation: the view is a function of state. You describe what the interface should look like for the current state, and React works out the DOM operations to get there.

The thing your component returns is not DOM and not a component instance. It is a plain object:

tsx
<AddressRow row={row} key="a1" />

// After the JSX transform, this is what exists at runtime:
{
  type: AddressRow,          // (1) a function, or a string like "div"
  key: "a1",                 // (2) pulled out of props deliberately
  props: { row: row },       // (3) everything else
  // …plus internal fields
}

Line (1) is the element's type: a string for a host element ("div", "input"), or the function itself for a component. Line (2) shows that key is not a prop — it is extracted by React and never reaches your component, which is why reading props.key gives undefined.

Three words that are constantly confused:

  • An element is that object. Cheap to create, immutable, describes one thing.
  • A component is the function you wrote.
  • An instance is React's internal record of a mounted component — where its state and effects live. You never touch it directly, and it is what the rest of this page is about.

The virtual DOM claim, honestly

The usual explanation is "React keeps a virtual DOM and diffs it, which is faster than touching the real DOM". That is not true as a performance claim, and it is worth being straight about it because interviewers ask.

Hand-written DOM code that updates exactly the one text node that changed is always faster than building a tree of element objects, diffing it, and then updating the same text node. React does strictly more work.

What React buys you is not speed; it is that you stop writing the update logic at all. You describe the result, and something else derives the operations. The value is that the code which handles thirty interacting pieces of state stays as simple as the code that handles one, and that the "which DOM nodes need to change" question — the part where hand-written code accumulates bugs over years — is answered mechanically every time.

React is fast enough because the diff is cheap relative to layout and paint, and because it batches updates into one DOM mutation pass instead of many. That is a fair claim. "Faster than the DOM" is not.

2. The two phases

Render phasecall components, build work-in-progress treeinterruptible · can be thrown awaycan be re-run · must be pureno DOM touched, no side effectsnothing the user can seeCommit phaseapply the DOM changes in one passNOT interruptible · runs to completionuseLayoutEffect — before paintbrowser paintsuseEffect — after paintswapa higher-priority update restarts the render — work discarded
The split that explains most of React's rules. Anything that can be thrown away and re-run must not have side effects.

The render phase calls your components and builds a description of the new tree. Nothing on screen changes. React may pause it, resume it, abandon it, or run it twice.

The commit phase applies the changes to the real DOM in a single pass and then runs effects. It cannot be interrupted.

This split is the origin of the rule that a component must be pure. "Pure" here means: same props and state in, same elements out, and no side effects along the way. If your component sends an analytics event during render and React abandons that render, you have sent an event for something that never happened. If it mutates a variable outside itself and React re-runs the render, the mutation happens twice. Every "why is my code running twice" question in React comes back to this, and Chapter 6.4.2 covers the StrictMode version of it directly.

3. Reconciliation: how React decides what changed

Comparing two arbitrary trees for the minimum set of changes is an O(n^3) problem. React makes it O(n) with two assumptions that are almost always true of real interfaces.

Assumption one: a different type means a different tree

tsx
// Before                    After
<div><Counter /></div>       <span><Counter /></span>

The root changed from div to span, so React destroys the entire subtree and rebuilds it. Counter is unmounted — its state is gone, its effects clean up — and a brand-new Counter mounts, even though it looks identical in both versions.

It does not try to work out that the child could be moved. The assumption is that a changed element type means a genuinely different piece of interface, and checking otherwise would cost more than it saves.

This is the mechanism behind a bug people hit constantly:

tsx
function Page({ isEditing }) {
  // A new component TYPE on every render — React sees a different function
  // each time and unmounts/remounts the whole subtree.
  const Panel = () => <ExpensiveForm />;    
  return <div>{isEditing ? <Panel /> : null}</div>;
}

Panel is a new function object on every render, so its type is never equal to last time's, so the subtree is thrown away and rebuilt on every parent render — losing all form state and re-running every effect. Never define a component inside another component. Move it out, or render <ExpensiveForm /> directly.

The same type-identity rule explains why conditionally wrapping content changes behaviour:

tsx
{withBorder ? <div className="bordered">{children}</div> : children}

Toggling withBorder changes the tree shape, so children unmounts and remounts, losing state. Rendering the wrapper always and toggling its class does not.

Assumption two: keys identify children across renders

Within a list, React matches children by key, not by position. Without a key it falls back to position, and position is exactly what changes when a list is reordered, filtered or has an item removed.

Now the opening bug, worked through.

Three rows with key={i} — keys 0, 1, 2. The user types "Lisbon" into row index 2, so the DOM input at position 2 holds that text and the component instance at position 2 holds its state. Delete the first row. The array now has two items, rendered with keys 0 and 1.

React looks at key 0: it existed before, so it updates it in place with new props. Key 1: same. Key 2: gone, so unmount it.

The result is that every component instance stayed where it was and only its props changed. But the uncontrolled DOM state — what the user typed, focus, scroll position, an open dropdown — belongs to the DOM node and the instance, not to the props. So the text stays at position 2 while the data that was at position 2 has moved to position 1. State and data have been silently separated.

With key={row.id}, React sees that the element with key "r-1" is gone and the elements with keys "r-2" and "r-3" remain. It removes the first DOM node and leaves the other two — with their inputs, their text, their focus — attached to the right records.

The rules that follow:

  • Keys must be stable (the same item gets the same key every render), unique among siblings (not globally), and derived from the data — an id from the server, or a generated id created when the item is created.
  • Index keys are safe only when the list is append-only, never reordered, never filtered, and the items have no state. That is a narrow set of cases, and it is easier to use ids than to keep proving the list qualifies.
  • Math.random() as a key is always wrong. A new key every render means every item unmounts and remounts every time, which is the worst possible outcome and looks like a mysterious performance problem.

A key can also be used deliberately to force a remount, which is occasionally exactly what you want:

tsx
// Changing the key throws away all internal state and starts the form fresh
// when the user switches to a different customer.
<CustomerForm key={customerId} customerId={customerId} />

4. Fiber: why React was rewritten

The original reconciler walked the tree with plain recursion. Recursion cannot be paused — once you are ten frames deep in a call stack, the only way out is to finish. So a large update occupied the main thread from start to end, and if that took 200 ms, the page was frozen for 200 ms (Chapter 6.1.1's rule again).

Fiber, shipped in React 16, replaced recursion with a data structure React walks itself.

Each element gets a fiber — an object holding the type, the props, the state, the effects to run, and, crucially, three pointers:

fiber = {
  type, key, stateNode,        // what it is, and the DOM node if it has one
  child,                       // first child
  sibling,                     // next sibling
  return,                      // parent  ("return" because it is where the walk goes back to)
  pendingProps, memoizedProps, memoizedState,
  flags,                       // what needs doing at commit: placement, update, deletion
  alternate,                   // the matching fiber in the other tree
}

Those three pointers turn the tree into a linked list that can be traversed with a loop instead of recursion. Go to child if there is one; otherwise sibling; otherwise walk up return until there is a sibling. The whole tree, no call stack.

And because it is a loop, React can stop between any two units of work:

js
// The shape of the work loop, simplified to the idea.
while (nextUnitOfWork !== null && !shouldYield()) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
// Out of time — hand the thread back to the browser, continue in the next slot.

shouldYield() asks whether the browser needs the thread — because there is pending input, or the frame budget is running out. This is time slicing, and it is what allows a large render to be spread over several frames while typing stays responsive.

Double buffering, and what alternate is for

React keeps two trees. The current tree is what is on screen. When an update comes, React builds a workInProgress tree, reusing fibers where nothing changed and cloning where it must. The alternate pointer links each fiber to its counterpart in the other tree.

When the work finishes, React swaps a single pointer: workInProgress becomes current. That is the commit.

This is why an interrupted render costs nothing visible. The half-built tree was never on screen, so abandoning it is free — no cleanup, no flicker, no torn interface. The technique is borrowed directly from graphics, where you draw into a back buffer and flip it to avoid showing a half-drawn frame.

It also explains the "two trees" you see in the profiler, and why React can afford to re-run a render it already started: the work is disposable by construction.

5. Concurrent rendering: priorities in practice

Fiber made interruption possible. Concurrent features make it useful, by letting React know which updates matter more.

Internally each update is tagged with a lane — a priority level. A keystroke is urgent. Filtering ten thousand rows because of that keystroke is not. Without priorities, both are the same work and the keystroke waits.

tsx
import { useState, useTransition, useDeferredValue } from 'react';

function ProductSearch({ allProducts }: { allProducts: Product[] }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();   // (1)
  const [results, setResults] = useState(allProducts);

  function onChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);                              // (2) urgent

    startTransition(() => {                                // (3) can be interrupted
      setResults(filterProducts(allProducts, e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={onChange} />
      <ResultList items={results} dimmed={isPending} />    {/* (4) */}
    </>
  );
}

Line (2) is an urgent update: the input must show the character immediately, or typing feels broken.

Line (3) marks the expensive update as a transition. React will render it at lower priority, and if another keystroke arrives mid-render, it throws that render away and starts again with the newer value. The user never waits for a stale result.

Line (1)'s isPending is true while the transition is in flight, which is how you show that results are catching up (line 4) without a spinner that flashes on every keystroke.

useDeferredValue is the same idea from the other end — use it when you cannot wrap the update because the value comes in as a prop:

tsx
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => filterProducts(all, deferredQuery), [all, deferredQuery]);

deferredQuery lags behind query during rapid changes, so the input is instant and the heavy list follows.

Be honest about the limit. These features let React interrupt work, not do it faster. A single component that takes 300 ms to render still takes 300 ms and cannot be sliced, because the unit of work is one component. If one component is slow, fix the component; transitions help when many components add up.

Automatic batching

Multiple state updates in the same tick produce one render:

tsx
function onSubmit() {
  setSaving(true);
  setError(null);
  setTouched(true);
  // One render, not three.
}

Before React 18 this only happened inside React event handlers. Now it happens everywhere — in promises, in setTimeout, in native event listeners. This changed behaviour for existing code: state that used to be readable between two updates in a .then() no longer is. flushSync forces an immediate synchronous render if you genuinely need the DOM updated before the next line, and it should be rare, because it defeats the batching that makes the rest fast.

When React skips work

React re-renders a component when its state changes, when its parent re-renders, or when a context it consumes changes. A parent re-rendering re-renders all its children by default, regardless of whether their props changed — the diff is cheap, so React does not check.

Two ways to stop that, and Chapter 6.4.2 covers the memoisation hooks properly:

React.memo wraps a component so it skips re-rendering when its props are shallowly equal to last time.

Element identity is the underrated one: if a child element object is the same object as last render, React bails out of that subtree without any memo at all. That is why passing children through works:

tsx
// <ExpensiveTree /> is created in the parent of Layout, so its element object
// is unchanged when Layout re-renders — React skips it entirely.
<Layout><ExpensiveTree /></Layout>

This is the cheapest optimisation in React and it requires no API: move the expensive part up and pass it down as children.

What the interviewer will push on

"Why not use array index as a key?" Because keys identify instances across renders, and an index changes meaning when the list is reordered or filtered. Then give the concrete failure: typed text and focus stay with the position while the data moves, so state attaches to the wrong record. Index keys are acceptable only for a static, append-only list of stateless items.

"Is the virtual DOM faster than direct DOM manipulation?" No — it is strictly more work than an optimal hand-written update. What it buys is that you never write the update logic, so complexity does not grow with the number of interacting states. Say this plainly; repeating the "faster" myth is a genuine tell.

"What is Fiber and what problem did it solve?" Recursion cannot be interrupted, so a big render froze the page. Fiber makes the tree a linked list walked by a loop, so React can yield between units of work. Add the double-buffering detail — two trees linked by alternate, commit is a pointer swap — because that is what makes discarding an interrupted render free.

"Why must a component be pure?" Because the render phase may be paused, abandoned, or run twice, and a side effect in a render that never commits is a side effect for something that did not happen.

"What does startTransition actually do?" Marks an update as interruptible so urgent updates can pre-empt it, and lets React throw away a stale in-progress render. Be clear about the limit: it does not make anything faster, and a single slow component still blocks.

"What happens when a parent re-renders?" All children re-render by default. Then give the two escapes: React.memo, and the cheaper one — passing an element as children so its object identity is unchanged and React bails out of the subtree with no API at all.

One thing to volunteer: mention that defining a component inside another component creates a new type on every render, so the entire subtree unmounts and remounts, silently destroying state. It is a real bug people ship, the cause is exactly the type-identity rule, and connecting the two shows you understand reconciliation rather than having memorised its rules.

Recall

  • A JSX element is a plain object{ type, key, props }. key is extracted by React and never reaches your component. Element ≠ component ≠ instance.
  • The virtual DOM is not a speed feature. It is strictly more work than an optimal hand update; what it buys is that you never write the update logic.
  • Render phase: calls components, builds a tree, no DOM, interruptible, may be abandoned or repeated — hence components must be pure. Commit phase: one DOM pass, uninterruptible, then useLayoutEffect (before paint) and useEffect (after paint).
  • Reconciliation uses two assumptions: a different element type destroys and rebuilds the subtree, and keys identify children within a list. Defining a component inside another creates a new type every render and remounts everything.
  • Index keys fail because typed text, focus and scroll live with the position while the data moves. Keys must be stable, unique among siblings, and from the data. Math.random() remounts everything.
  • Changing a key on purpose is the clean way to reset a component's state.
  • Fiber replaces recursion with child/sibling/return pointers, so the tree is walked by a loop that can yield to the browser between units of work (time slicing).
  • Double buffering: current and workInProgress trees linked by alternate; commit is a pointer swap, which is why discarding an interrupted render is free.
  • startTransition and useDeferredValue mark work as interruptible so a keystroke pre-empts a heavy list. They do not make anything faster — one slow component still blocks.
  • Updates are automatically batched everywhere since React 18. A parent re-render re-renders all children; escape with React.memo or by passing an element as children so its identity is unchanged.

Self-test: Exactly what state is lost when an index key shifts? · Why can React afford to abandon a half-finished render? · What breaks if a component is impure? · Why does defining a component inside another destroy state? · What does startTransition not fix?

Next: 6.4.2 goes inside a single component — how a function with no this remembers anything between calls, why the rules of hooks are a consequence of that mechanism rather than a style guide, and what StrictMode's double invocation is actually testing.