Reconciliation

Published:
  1. render calculates the next host description
  2. commit changes the DOM to match it

That model explains when DOM mutation happens, but not which mutations the renderer should make. After the first commit, there are two things to consider: the DOM that already exists and the description produced by the next render.

That leaves the next hole:

once something is already on the page, how does React update it without rebuilding everything?

We will start with the smallest update strategy that can produce the right visible markup, inspect where it fails, and add only the identity information each failure demands.

Rebuild from scratch #

Suppose the renderer ignores the old DOM. For every new description, it can create a complete new subtree and replace the old one.

We do not need React to try this strategy. A tiny browser renderer makes the attempt visible:

function buildStatus(online) {
  const container = document.createElement('div');
  const paragraph = document.createElement('p');
  const name = document.createElement('strong');
  const input = document.createElement('input');

  paragraph.className = online ? 'online' : 'offline';
  name.textContent = 'Maya';
  paragraph.append(name, online ? ' is online' : ' is offline');
  input.setAttribute('aria-label', 'Draft');
  container.append(paragraph, input);

  return container;
}

function commitStatus(online) {
  mountNode.replaceChildren(buildStatus(online));
}

commitStatus(false);

const oldInput = mountNode.querySelector('input');
oldInput.value = 'unfinished note';

commitStatus(true);

const newInput = mountNode.querySelector('input');
console.log('status:', mountNode.querySelector('p').textContent);
console.log('same input:', oldInput === newInput);
console.log('input value:', newInput.value);

Waiting to run

Not run yet.

The second commit produces the requested online status. If visible markup were the only requirement, this strategy would be enough.

The input exposes the failure. Its value belonged to the existing DOM node, not to our next description. replaceChildren(...) removes that node and inserts a new subtree.[4] The new input is empty, and the identity comparison is false.

The rebuild strategy discarded information that did not need to change. Focus, text selection, scroll position, media playback, and other host-owned state can have the same problem.

That gives us the first constraint:

an update should preserve an existing host node when the old and new descriptions refer to the same thing

But “the same thing” is not a field we can read from the DOM. The renderer needs a rule for deciding when an old description and a new description correspond.

Preserving a corresponding node #

Use React for the same kind of status update and keep references to the nodes that already exist:

const { useState } = React;

function Status() {
  const [online, setOnline] = useState(false);

  return (
    <div>
      <p className={online ? 'online' : 'offline'}>
        <strong>Maya</strong> is {online ? 'online' : 'offline'}
      </p>
      <button onClick={() => setOnline(!online)}>
        Change status
      </button>
    </div>
  );
}

root.render(<Status />);

setTimeout(() => {
  const oldParagraph = mountNode.querySelector('p');
  const oldName = mountNode.querySelector('strong');
  mountNode.querySelector('button').click();

  setTimeout(() => {
    const newParagraph = mountNode.querySelector('p');
    const newName = mountNode.querySelector('strong');

    console.log('same paragraph:', oldParagraph === newParagraph);
    console.log('same name node:', oldName === newName);
    console.log('class:', newParagraph.className);
    console.log('text:', newParagraph.textContent);
  }, 0);
}, 20);

Waiting to run

Not run yet.

The new render changes the paragraph's className and final text. The identity checks show that React kept both the paragraph and the <strong> node.

React did not use our rebuild strategy. It changed the parts whose rendered values differed and left corresponding DOM nodes in place.[1]

That fills the first hole, but only by opening a more precise one:

what made the old paragraph correspond to the new paragraph?

The descriptions give React a type, props, and a position in a tree. We can vary those pieces one at a time.

Position alone is not identity #

The paragraph remained in the same child position during the status update. Perhaps position beneath a parent is enough to preserve a host node.

Keep one child in that position, but change its host type:

const { useState } = React;

function Editor() {
  const [prominent, setProminent] = useState(false);
  const content = (
    <input aria-label="Draft" defaultValue="" />
  );

  return (
    <div>
      {prominent
        ? <section>{content}</section>
        : <aside>{content}</aside>}
      <button onClick={() => setProminent(!prominent)}>
        Change container
      </button>
    </div>
  );
}

root.render(<Editor />);

setTimeout(() => {
  const oldContainer = mountNode.querySelector('aside');
  const oldInput = mountNode.querySelector('input');
  oldInput.value = 'unfinished note';
  mountNode.querySelector('button').click();

  setTimeout(() => {
    const newContainer = mountNode.querySelector('section');
    const newInput = mountNode.querySelector('input');

    console.log('old type:', oldContainer.tagName);
    console.log('new type:', newContainer.tagName);
    console.log('same container:', oldContainer === newContainer);
    console.log('same input:', oldInput === newInput);
    console.log('input value:', newInput.value);
  }, 0);
}, 20);

Waiting to run

Not run yet.

The child position did not change, but the old container was an <aside> and the new container is a <section>. React removes the old host subtree and creates the new one, so the input beneath it is new as well. Its DOM-owned draft disappears with the old node.

Position alone was too weak. The renderer cannot update an <aside> until it becomes a <section>; those descriptions require different kinds of host nodes.

That failure adds the next constraint:

an old element and a new element can preserve host identity only when their types are compatible

Our working rule is now:

  • the same host type at the same position can be updated in place
  • a different host type at that position starts a different host subtree

This works for the host examples so far. Components make the rule harder because a preserved component also preserves state.

Type and position preserve too much #

Apply the rule to a component. Keep one Score type in the same child position while changing its player prop:

const { useState } = React;

function Score({ player }) {
  const [points, setPoints] = useState(0);

  return (
    <section>
      <h2>{player}: {points}</h2>
      <button
        data-action="score"
        onClick={() => setPoints(points + 1)}
      >
        Score a point
      </button>
    </section>
  );
}

function Scoreboard() {
  const [player, setPlayer] = useState('Taylor');

  return (
    <div>
      <Score player={player} />
      <button
        data-action="next"
        onClick={() => setPlayer('Jane')}
      >
        Next player
      </button>
    </div>
  );
}

root.render(<Scoreboard />);

setTimeout(() => {
  mountNode.querySelector('[data-action="score"]').click();

  setTimeout(() => {
    mountNode.querySelector('[data-action="next"]').click();

    setTimeout(() => {
      console.log(mountNode.querySelector('h2').textContent);
    }, 0);
  }, 0);
}, 20);

Waiting to run

Not run yet.

Taylor scores one point. After the prop changes, the heading says Jane: 1.

React followed the working rule. The tree still contains the same Score type in the same position beneath the <div>, so React preserves the state associated with that position.[2]

Mechanically, the match is consistent. For the application, the result is wrong: Taylor's point became Jane's point.

React cannot infer from the player prop that the application considers these two scoreboards distinct. Props change during ordinary updates; resetting state for every prop change would destroy useful state as soon as a label, color, or callback changed.

Type and position therefore preserve too much for this case. The failed scoreboard leaves another hole:

how can a description say that the same component type in the same position represents a different identity?

Adding explicit identity #

The description needs one more piece chosen by the application. React calls that piece a key.

Add the player name as a key on Score:

const { useState } = React;

function Score({ player }) {
  const [points, setPoints] = useState(0);

  return (
    <section>
      <h2>{player}: {points}</h2>
      <button
        data-action="score"
        onClick={() => setPoints(points + 1)}
      >
        Score a point
      </button>
    </section>
  );
}

function Scoreboard() {
  const [player, setPlayer] = useState('Taylor');

  return (
    <div>
      <Score key={player} player={player} />
      <button
        data-action="next"
        onClick={() => setPlayer('Jane')}
      >
        Next player
      </button>
    </div>
  );
}

root.render(<Scoreboard />);

setTimeout(() => {
  mountNode.querySelector('[data-action="score"]').click();

  setTimeout(() => {
    mountNode.querySelector('[data-action="next"]').click();

    setTimeout(() => {
      console.log(mountNode.querySelector('h2').textContent);
    }, 0);
  }, 0);
}, 20);

Waiting to run

Not run yet.

Now the heading says Jane: 0. At the same parent position, React sees Score with key Taylor followed by Score with key Jane. The keys do not match, so the old component identity is removed and a new state slot begins.

The key fills the hole because it expresses the distinction the type and position could not. Taylor and Jane use the same component implementation, but they are not the same rendered child.

This does not make a key a state cache. If the scoreboard switches back to Taylor after removing Taylor's Score, a fresh Taylor state begins unless some parent or external store preserved the old value. The key tells React whether two rendered children correspond; it does not keep an absent component alive.

We introduced a key for one conditional child. The earlier lists article required keys for repeated siblings. Reordering those siblings shows why the same identity mechanism is necessary there.

Position fails for repeated children #

Start without keys. React then has only sibling position available to distinguish several TaskRow children of the same type.

Render three task rows without keys, type into the first uncontrolled input, and reverse the task data:

const { useState } = React;

const initialTasks = [
  { id: 'draft', label: 'Draft article' },
  { id: 'review', label: 'Review examples' },
  { id: 'publish', label: 'Publish notes' },
];

function TaskRow({ task }) {
  return (
    <li data-task-id={task.id}>
      <span>{task.label}</span>
      <input aria-label={task.label + ' note'} />
    </li>
  );
}

function TaskList() {
  const [tasks, setTasks] = useState(initialTasks);

  return (
    <div>
      <button onClick={() => setTasks([...tasks].reverse())}>
        Reverse
      </button>
      <ul>
        {tasks.map((task) => (
          <TaskRow task={task} />
        ))}
      </ul>
    </div>
  );
}

root.render(<TaskList />);

setTimeout(() => {
  const firstInput = mountNode.querySelector('input');
  firstInput.value = 'opening paragraph';
  mountNode.querySelector('button').click();

  setTimeout(() => {
    const firstRow = mountNode.querySelector('li');
    console.log('first task:', firstRow.dataset.taskId);
    console.log('first note:', firstRow.querySelector('input').value);
    console.log('same first input:', firstInput === firstRow.querySelector('input'));
  }, 0);
}, 20);

Waiting to run

Not run yet.

React warns that the list children have no keys. After the reversal, the first row describes publish, but its input still contains the note typed for draft. The input node stayed in the first position while different task data arrived there.

Again, React followed the information it had. The first old TaskRow and the first new TaskRow have the same type and position, so they match. The missing information is that the draft task moved rather than becoming the publish task.

The failure sharpens the key constraint:

when data items can move among same-type siblings, identity must follow the data rather than the current position

Stable keys restore data identity #

The task IDs already express the identity we need. Put each ID on the TaskRow element created by map():

const { useState } = React;

const initialTasks = [
  { id: 'draft', label: 'Draft article' },
  { id: 'review', label: 'Review examples' },
  { id: 'publish', label: 'Publish notes' },
];

function TaskRow({ task }) {
  return (
    <li data-task-id={task.id}>
      <span>{task.label}</span>
      <input aria-label={task.label + ' note'} />
    </li>
  );
}

function TaskList() {
  const [tasks, setTasks] = useState(initialTasks);

  return (
    <div>
      <button onClick={() => setTasks([...tasks].reverse())}>
        Reverse
      </button>
      <ul>
        {tasks.map((task) => (
          <TaskRow key={task.id} task={task} />
        ))}
      </ul>
    </div>
  );
}

root.render(<TaskList />);

setTimeout(() => {
  const draftInput = mountNode.querySelector(
    '[data-task-id="draft"] input',
  );
  draftInput.value = 'opening paragraph';
  mountNode.querySelector('button').click();

  setTimeout(() => {
    const movedDraftInput = mountNode.querySelector(
      '[data-task-id="draft"] input',
    );
    const rows = [...mountNode.querySelectorAll('li')];

    console.log(
      'order:',
      rows.map((row) => row.dataset.taskId).join(', '),
    );
    console.log('draft note:', movedDraftInput.value);
    console.log('same draft input:', draftInput === movedDraftInput);
  }, 0);
}, 20);

Waiting to run

Not run yet.

The order becomes publish, review, draft, but the draft input and its value move with the draft row. React can match the old child with key draft to the new child with key draft, even though its sibling position changed.

This is why a key must be stable and come from the data rather than from the current array index. Keys tell React which array item each component corresponds to, which matters when items move, are inserted, or are removed.[3]

Keys are local, not global. Two different lists can both contain a child with key draft. A key only needs to distinguish that element from its siblings under the same parent.

We can now state the matching inputs forced by the experiments:

  1. the element or component type
  2. the parent it belongs beneath
  3. its key when one is present, or otherwise its position among that parent's children

Reuse does not skip rendering #

The matching model creates one last tempting shortcut. If a host node can be reused, perhaps React can skip calling the component that described it.

Update a parent while keeping one child's visible result the same, and log that child's calculation:

const { useState } = React;
let renderCount = 0;

function Greeting({ name }) {
  renderCount += 1;
  console.log('render Greeting:', renderCount);
  return <h2>Hello, {name}</h2>;
}

function App() {
  const [requests, setRequests] = useState(0);

  return (
    <div>
      <Greeting name="Maya" />
      <button onClick={() => setRequests(requests + 1)}>
        Render again: {requests}
      </button>
    </div>
  );
}

root.render(<App />);

setTimeout(() => {
  const oldHeading = mountNode.querySelector('h2');
  mountNode.querySelector('button').click();

  setTimeout(() => {
    const newHeading = mountNode.querySelector('h2');
    console.log('render count:', renderCount);
    console.log('same heading:', oldHeading === newHeading);
  }, 0);
}, 20);

Waiting to run

Not run yet.

Greeting runs again after its parent update, so the shortcut did not happen. Yet the <h2> is still the same DOM node.

That separates two kinds of reuse. React can call a component to produce a new description and still preserve the corresponding host node when that description is matched. Matching host nodes is not evidence that component calculation was skipped.

These are separate questions:

  • Did a component render? React called it to calculate a description.
  • Did a host node survive? Its old and new elements matched.
  • Did the DOM change? Some property, text, child, or identity differed enough to require a commit mutation.

A component can render while its host nodes remain unchanged. Conversely, changing a type or key can replace a whole subtree even when much of its visible content looks similar.

Filling the hole #

We started with a renderer that rebuilt the entire subtree. It produced the requested markup, but it destroyed an unchanged input and the browser-owned value inside it. That failure required a way to preserve corresponding host nodes.

Each later attempt added one constraint:

  1. preserving by position alone failed when the host type changed
  2. matching by type and position preserved component state
  3. that preservation was wrong when two players occupied the same component position
  4. a key supplied the missing application identity and reset the state
  5. positional identity failed again when unkeyed task data moved
  6. stable task keys let the host nodes move with their data
  7. a final render showed that reusing a host node does not mean the component was not called

Reconciliation connects render to commit. While React calculates the next tree, it determines how that result corresponds to the previous tree. Commit then performs the resulting host mutations.

This model does not require application code to reproduce React's internal algorithm. It gives application code the pieces it controls: types, tree positions, and keys.

Final definition #

Reconciliation is React's process of matching a newly rendered tree with the previously rendered tree so it can preserve corresponding component state and host nodes, replace identities that no longer match, and determine the host mutations needed for the next commit. Element type and position provide the default identity; keys let authors identify siblings across structural changes or deliberately reset identity.

Summary #

Reconciliation fills the update hole left by render and commit:

  • rebuilding produces correct new markup but destroys host state unnecessarily
  • React therefore matches old and new descriptions before committing changes
  • matching host elements can keep their DOM nodes
  • changing an element type replaces that part of the tree
  • component state is associated with a type at a position in the rendered tree
  • changing a key tells React that the component identity changed
  • unkeyed siblings fall back to positional identity
  • stable keys let list identity follow the data when order changes
  • a component render does not imply a DOM mutation
  • reconciliation determines correspondence; commit applies the necessary host changes

Notes

  1. The live demos in this article use the React 19.2.5 and react-dom 19.2.5 development modules. They build an author-facing model from observable behavior rather than reproducing React's internal implementation.

References

  1. React: Render and Commit (opens in a new tab) · Back
  2. React: Preserving and Resetting State (opens in a new tab) · Back
  3. React: Rendering Lists (opens in a new tab) · Back
  4. MDN: `Element.replaceChildren()` (opens in a new tab) · Back