Ch. 3 · React

React Reconciliation & Keys: What Really Happens

Render vs commit, the diffing rules React relies on, why index keys break inputs, how a key resets state, and what Fiber and batching change about updates.

~7 min readintermediate

Reconciliation is React’s name for working out what changed between two renders and doing the least work needed to update the screen. Interviewers ask about it because it explains a surprising number of everyday bugs: an input that keeps the wrong text after a list is reordered, state that resets for no obvious reason, or state that refuses to reset when you want it to. The rules are few, and once you know them, those bugs become predictable.

Render and commit are separate phases

Every update goes through the same steps. Something triggers a render (the initial mount, or a state update). React renders: it calls your components to compute what the UI should look like. Then React commits: it applies the differences to the DOM.

“Rendering” doesn’t mean touching the DOM. A component can render and return exactly what it returned last time, and React will then change nothing on screen. During commit, React only updates DOM nodes whose output actually changed:

function Clock({ time }: { time: string }) {
  return (
    <>
      <h1>{time}</h1>
      <input placeholder="Type here" />
    </>
  );
}
TSX

Each tick re-renders Clock, but only the h1 text changes in the DOM. The input is the same element type at the same position, so React leaves it alone, and whatever you typed survives.

After the DOM is updated, React runs layout effects (useLayoutEffect), the browser paints, and passive effects (useEffect) run, usually after that paint. The render phase must be pure, because, as you’ll see below, React may call it more than once before committing.

How React diffs two trees

A general algorithm for the minimal set of changes between two trees is roughly O(n³). React uses heuristics that make it O(n) instead, and they hold up well in practice:

  1. Different element type at the same position: React tears down the old subtree (unmounting components and destroying their state) and builds the new one from scratch.
  2. Same type at the same position: React keeps the DOM node or component instance, along with its state, updates the props that changed, and recurses into the children.
  3. Lists of children are matched by key, or by index if there are no keys.
// Switching branches remounts Counter: its state resets,
// because a div and a section are different types
{isEditing ? <div><Counter /></div> : <section><Counter /></section>}

// Same type at the same position: Counter keeps its state
{isFancy ? <Counter fancy /> : <Counter />}
TSX

State belongs to a position in the tree, not to the JSX you wrote. The second example looks like two different counters, but React only sees “a Counter at child 0” both times.

Gotcha

Never define a component inside another component. Each render creates a new function, which React treats as a new type, so the inner component remounts and loses its state (and input focus) every time the parent renders.

function Page() {
  // 🐛 A brand-new component type on every render of Page
  function SearchBox() {
    const [text, setText] = useState("");
    return <input value={text} onChange={(e) => setText(e.target.value)} />;
  }
  return <SearchBox />;
}
TSX

Keys give list items identity

Without keys, React matches list children by position: old child 0 against new child 0, and so on. With keys, it matches by key among siblings, so it can tell that an item moved, rather than that every position changed.

<ul>
  {todos.map((todo) => (
    <TodoItem key={todo.id} todo={todo} />
  ))}
</ul>
TSX

The rules are short. Keys must be unique among siblings (not globally), and stable across renders, so they should come from your data, not be generated during render. The key isn’t passed to your component as a prop; if the component needs the id, pass it separately. If you leave keys out, React warns in development and falls back to the index.

The index-as-key bug

Here’s the bug interviewers like to see you explain. Each row keeps a note in local state:

function Row({ label }: { label: string }) {
  const [note, setNote] = useState("");
  return (
    <li>
      {label}: <input value={note} onChange={(e) => setNote(e.target.value)} />
    </li>
  );
}

function Guests() {
  const [guests, setGuests] = useState([{ id: "a1", name: "Ada" }]);
  const addToTop = () =>
    setGuests((g) => [{ id: crypto.randomUUID(), name: "Linus" }, ...g]);

  return (
    <>
      <button onClick={addToTop}>Add guest</button>
      <ul>
        {guests.map((guest, index) => (
          <Row key={index} label={guest.name} /> // 🐛 index as key
        ))}
      </ul>
    </>
  );
}
TSX

Type “vegetarian” next to Ada, then click “Add guest”. The list is now Linus, Ada. React compares keys, not people:

First row Second row
Before Ada, note “vegetarian” (none)
After, key={index} Linus, note “vegetarian” Ada, note “”
After, key={guest.id} Linus, note “” Ada, note “vegetarian”

With index keys, key 0 still exists and is still a Row, so React keeps its state and just changes the label prop to “Linus”. Key 1 is new, so Ada gets a fresh, empty row. Linus is now vegetarian and Ada’s note is gone. With key={guest.id}, React sees that "a1" moved to position 1, keeps its state with it, and mounts a new row for Linus.

Interview tip

Say when index keys are fine: the list is never reordered, filtered or inserted into, and the items hold no state (including uncontrolled inputs). Also mention the opposite mistake: key={Math.random()} makes every key new on every render, so every row remounts and loses its input.

Using key to reset state

Keys aren’t only for lists. Because a key is part of a component’s identity, changing it on a single element tells React this is a different component: the old one unmounts and a new one mounts with fresh state.

function Inbox({ contact }: { contact: Contact }) {
  // A new contact means a new ChatDraft, with an empty draft
  return <ChatDraft key={contact.id} contact={contact} />;
}
TSX

Without the key, switching from Alice to Bob keeps the same ChatDraft at the same position, and Alice’s half-written message stays in the box. The key version is also better than an effect that clears state when contact changes: the effect approach renders once with the stale draft before fixing it. More on that in useEffect Pitfalls Interviewers Love to Ask.

Fiber and interruptible rendering

Fiber is the architecture of React’s reconciler, introduced in React 16. Each component in the tree is represented by a fiber: an object holding its type, props, state and hooks, plus pointers to its parent, first child and next sibling. React keeps the tree that’s on screen and builds a work-in-progress copy during render; committing swaps them.

Because rendering is split into small units of work, React can pause between units to let the browser handle input, then resume, restart with newer data, or throw the work away. Updates get priorities: a keystroke is urgent, while an update wrapped in startTransition can be interrupted.

function Search() {
  const [text, setText] = useState("");
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <input
        value={text}
        onChange={(e) => {
          setText(e.target.value); // urgent: keep typing responsive
          startTransition(() => setQuery(e.target.value)); // interruptible
        }}
      />
      {isPending && <p>Updating…</p>}
      <SlowResults query={query} />
    </>
  );
}
TSX

The commit phase is never interrupted, so users never see a half-applied update. The flip side: a render may run and be discarded without ever committing, which is one more reason rendering must be pure.

Note

Fiber, lanes and the work-in-progress tree are implementation details. In an interview, the mechanism matters less than the consequences: interruptible rendering, prioritized updates and a synchronous commit.

Batching

React batches state updates: several set calls in the same event produce one re-render, not one per call. Since React 18 (with createRoot), this automatic batching also applies inside promises, timeouts and native event handlers, not only React event handlers.

function handleClick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // one re-render with both updates
}

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1); // +1, not +3: each call saw the same `count`
}
TSX

The second handler is about snapshots rather than batching: count is a constant for the whole render, so all three calls ask for the same value, and React applies them after the handler finishes. The new value only shows up on the next render. Use the updater form, setCount((c) => c + 1), when an update depends on the previous one. In the rare case you need the DOM updated immediately (say, to scroll to a newly added item), flushSync from react-dom forces a synchronous update.

The interview answer

“When state changes, React re-renders: it calls the affected components to build a new element tree, diffs it against the previous one, and then commits only the differences to the DOM. The diff relies on two heuristics. If the element type at a position changes, React unmounts that subtree and mounts a new one, losing state. If the type is the same, it keeps the instance and updates props. For lists it matches children by key, which is why keys must be stable and unique among siblings: with index keys, inserting at the top makes state stick to positions instead of items. The same idea lets me reset a component on purpose by changing its key.

Under the hood, Fiber splits rendering into interruptible units of work with priorities, so urgent updates like typing can cut in front of transitions, while the commit itself is always synchronous. Updates are batched, so several set calls in one event cause a single render.”

More in React

read ✓React · mid

useEffect Pitfalls Interviewers Love to Ask

Stale closures, missing cleanup, fetch races, Strict Mode's double run, infinite loops and effects you don't need, plus how useLayoutEffect differs in timing.

~7 min readread →
read ✓System Design · hard

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readread →
esc