Kodokon kodokon.com

Under the hood: reconciliation, batching, concurrent rendering

Discover how Fiber reconciles the tree, batches updates, and makes rendering work interruptible.

11 min · 3 questions

Open this lesson in Kodokon

Since React 16, the internal engine has been called Fiber: every element in your tree is represented by a fiber node, a unit of work. React actually maintains two trees (a double-buffering technique): the current tree, which reflects what is on screen, and the workInProgress tree, built during rendering. The work happens in two distinct phases: the render phase (calling your components, computing differences), which is interruptible and replayable, then the commit phase (applying mutations to the DOM, running effects), which is always synchronous and uninterruptible. Reconciliation relies on two heuristics that bring the comparison down to linear cost: if an element's type changes at a given position, React destroys the entire subtree and rebuilds it, state included; for lists, the key prop lets React match elements between two renders.

JSX
function List({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <input defaultValue={item.label} />
        </li>
      ))}
    </ul>
  );
}
// With key={index}, deleting the first item
// would shift the content of the remaining inputs.
A stable key preserves each row's state and DOM.

The second fundamental mechanism: batching. When several state updates are triggered within the same context, React groups them into a single render. Before React 18, this grouping only worked inside React event handlers; since createRoot, it is automatic everywhere: timers, promises, native listeners. This is also why reading state right after a setState returns the old value: the update is scheduled, not applied.

JSX
function handleTick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // React 18: a single render, even here.
}

setTimeout(handleTick, 100);
// Before React 18, this timer would trigger
// two successive renders.
Automatic batching now covers every context.

Concurrent rendering pushes the logic further: the render phase can be interrupted, resumed, or even discarded and restarted if a more urgent update comes in. React ranks updates by priority (the lanes): a keystroke is urgent, a list filter marked with startTransition is not. The direct consequence: your component functions must be pure, because React may call them several times without ever committing the result. Another edge case: an external store (Redux, Zustand, a homemade singleton) read during an interruptible render can change mid-render, producing an inconsistent interface known as tearing. The useSyncExternalStore API solves this problem by forcing a synchronous render when the store changes.

JAVASCRIPT
import { useSyncExternalStore } from 'react';

function useWindowWidth() {
  return useSyncExternalStore(
    (notify) => {
      window.addEventListener('resize', notify);
      return () =>
        window.removeEventListener('resize', notify);
    },
    () => window.innerWidth
  );
}
Reading an external store with no risk of tearing.

Knowledge check

Make sure you remember the key points of this lesson.

  1. During reconciliation, what does React do if a <div> element becomes a <span> at the same position?
    • It updates the tag while keeping the children
    • It destroys the entire subtree, state included, and rebuilds it
    • It recursively compares the children before deciding
    • It throws a reconciliation error
  2. With React 18 and createRoot, how many renders do two setState calls inside a setTimeout trigger?
    • Two, because batching only applies to React events
    • Just one, thanks to automatic batching
    • Zero, updates outside events are ignored
  3. Which phase of React's work is interruptible in concurrent mode?
    • The commit phase only
    • The render phase only
    • Both phases
    • Neither, React stays fully synchronous