Skip to content

Rendering & Reconciliation

A common misconception: “re-render” means “React touches the DOM.” It doesn’t. React splits the work into two distinct phases:

flowchart LR
  trigger["state change /
parent re-render"] --> render["Render phase
(call components, build new tree)
— pure, no DOM"]
  render --> recon["Reconciliation
(diff new tree vs old)"]
  recon --> commit["Commit phase
(apply minimal DOM changes)
— side effects here"]
The two phases of an update
  1. Render phase — React calls your components to build a new tree of elements. This is pure computation: no DOM is touched, and React can pause or throw it away.
  2. Commit phase — React compares the new tree to the previous one, computes the minimal set of DOM operations, and applies them.

So a re-render that produces the same output as before results in zero DOM changes. Rendering is cheap-ish; the expensive part (touching the DOM) only happens for what actually changed. (Exactly how React schedules and interleaves this work — fibers, lanes, the work loop — is covered in the internals module.)

Comparing two arbitrary trees is famously expensive (O(n³)). React makes it fast (O(n)) with two heuristics, and both are provable, not just described:

  • Different type → throw it away. If an element’s type changed (<div> became <span>, or ComponentA became ComponentB), React unmounts the old subtree entirely and builds the new one fresh — including destroying its state.
  • Same type → keep the node, update props. If the type is the same, React keeps the existing DOM node and its state, and just updates the changed props — no mount/unmount at all.
src/__tests__/reconciliation-rules.test.tsx
import { useEffect } from 'react';
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('a different element type unmounts the old subtree and mounts a new one', () => {
const log: string[] = [];
function Field({ as }: { as: 'input' | 'p' }) {
useEffect(() => {
log.push(`mount:${as}`);
return () => { log.push(`unmount:${as}`); };
}, [as]);
return as === 'input' ? <input defaultValue="hi" /> : <p>hi</p>;
}
const { rerender } = render(<Field as="input" />);
log.length = 0;
rerender(<Field as="p" />); // type changed: input -> p
expect(log).toEqual(['unmount:input', 'mount:p']);
});
test('the same element type across renders updates props in place — no remount', () => {
const log: string[] = [];
function Row({ disabled }: { disabled: boolean }) {
useEffect(() => {
log.push('mount');
return () => { log.push('unmount'); };
}, []);
return <input disabled={disabled} />;
}
const { rerender } = render(<Row disabled={false} />);
log.length = 0;
rerender(<Row disabled={true} />); // same type: input -> input
expect(log).toEqual([]); // no mount/unmount — only the prop changed
});
✓ a different element type unmounts the old subtree and mounts a new one
✓ the same element type across renders updates props in place — no remount
Test Files 1 passed (1)
Tests 2 passed (2)
// Same type across renders → the <input> keeps its DOM node and its focus/value.
{isEditing ? <input value={text} /> : <input value={text} disabled />}
// Different type → React destroys the first and mounts the second (state lost).
{isEditing ? <input value={text} /> : <p>{text}</p>}

For a list of siblings of the same type, React needs to know which item is which across renders. By default it matches by position (index) — which breaks the moment items reorder, insert, or delete. A key gives each item a stable identity so React can track it, instead of tracking the slot it happens to sit in.

src/__tests__/position-vs-key.test.tsx
import { useState } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
type Item = { id: string; text: string };
function Checkbox({ label }: { label: string }) {
const [checked, setChecked] = useState(false);
return (
<label>
<input type="checkbox" checked={checked} onChange={() => setChecked((c) => !c)} />
{label}
</label>
);
}
function List({ items, useIndexKey }: { items: Item[]; useIndexKey: boolean }) {
return (
<ul>
{items.map((item, i) => (
<li key={useIndexKey ? i : item.id}>
<Checkbox label={item.text} />
</li>
))}
</ul>
);
}
test('index-as-key: prepending an item moves the checked state to the WRONG row', () => {
const items: Item[] = [{ id: 'a', text: 'Alpha' }, { id: 'b', text: 'Bravo' }];
const { getAllByRole, rerender } = render(<List items={items} useIndexKey />);
fireEvent.click(getAllByRole('checkbox')[0]); // check "Alpha" (currently index 0)
const withPrepend = [{ id: 'z', text: 'Zulu' }, ...items];
rerender(<List items={withPrepend} useIndexKey />);
const checked = getAllByRole('checkbox').map((c) => (c as HTMLInputElement).checked);
// BUG: index 0 is now "Zulu", but the checked=true STATE stayed pinned to
// position 0 — Zulu shows checked, not Alpha.
expect(checked).toEqual([true, false, false]);
});
test('stable id-as-key: the same reorder keeps the checked state on the correct item', () => {
const items: Item[] = [{ id: 'a', text: 'Alpha' }, { id: 'b', text: 'Bravo' }];
const { getAllByRole, rerender, getByText } = render(<List items={items} useIndexKey={false} />);
fireEvent.click(getAllByRole('checkbox')[0]);
const withPrepend = [{ id: 'z', text: 'Zulu' }, ...items];
rerender(<List items={withPrepend} useIndexKey={false} />);
const alphaCheckbox = getByText('Alpha').closest('label')!.querySelector('input') as HTMLInputElement;
const zuluCheckbox = getByText('Zulu').closest('label')!.querySelector('input') as HTMLInputElement;
expect(alphaCheckbox.checked).toBe(true); // followed Alpha, wherever it moved
expect(zuluCheckbox.checked).toBe(false);
});
✓ index-as-key: prepending an item moves the checked state to the WRONG row
✓ stable id-as-key: the same reorder keeps the checked state on the correct item
Test Files 1 passed (1)
Tests 2 passed (2)

The rule: a key must be stable, unique among siblings, and tied to the data’s identity (usually a database id) — never the array index for a list that can change, and never Math.random() (which changes every render and forces a full remount of every item, every time).

Key as a reset switch (a different use of the same mechanism)

Section titled “Key as a reset switch (a different use of the same mechanism)”

List identity is one job for key — but the same “same key → same instance, different key → remount” rule is also a deliberate tool on its own, unrelated to lists: put an explicit, changing key on a single component whenever you want React to throw away its entire state and start fresh, instead of updating it in place.

src/__tests__/key-as-reset-switch.test.tsx
import { useState, useEffect } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('changing key remounts the component and resets its internal state, even with the same props', () => {
const log: string[] = [];
function Draft({ label }: { label: string }) {
const [count, setCount] = useState(0);
useEffect(() => {
log.push(`mount:${label}`);
return () => { log.push(`unmount:${label}`); };
}, [label]);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
const { getByRole, rerender } = render(<Draft key="a" label="a" />);
fireEvent.click(getByRole('button'));
expect(getByRole('button').textContent).toBe('1');
log.length = 0;
rerender(<Draft key="b" label="a" />); // key changes even though `label` prop did NOT
expect(log).toEqual(['unmount:a', 'mount:a']); // forced remount
expect(getByRole('button').textContent).toBe('0'); // internal state reset
});
✓ changing key remounts the component and resets its internal state, even with the same props
Test Files 1 passed (1)
Tests 1 passed (1)

This is the standard fix for “this form/editor keeps showing stale state after I switched what it’s editing” — put key={editingItemId} on the component, and switching ids forces a clean remount instead of the component carrying over stale internal state from the previous id.

flowchart TB
  before["Before: [A, B, C]
keys a,b,c"] -->|prepend Z| after["After: [Z, A, B, C]
keys z,a,b,c"]
  after --> good["With stable keys:
A,B,C reused, Z mounted"]
  before2["Before: [A, B, C]
key = index 0,1,2"] -->|prepend Z| after2["After: index 0,1,2,3"]
  after2 --> bad["With index keys:
every item mismatched,
state shifts to wrong rows"]
  same["Same component, key changes
(e.g. key={itemId})"] --> reset["Forced unmount + remount:
state resets on purpose"]
Keys let reconciliation track items across a reorder — or force a reset

See it render: parent tick, a memoized child, a key reset

Section titled “See it render: parent tick, a memoized child, a key reset”
import { useState, memo } from 'react';
const MemoChild = memo(function MemoChild({ label }: { label: string }) {
return <li>Memo child — stays put unless its own props change: {label}</li>;
});
function Draft({ resetKey }: { resetKey: number }) {
const [text, setText] = useState('');
return (
<div>
<p>Draft (key={resetKey}) — internal state below resets when you click "New draft":</p>
<input value={text} onChange={(e) => setText((e.target as HTMLInputElement).value)} placeholder="type here, then click New draft" />
</div>
);
}
export default function App() {
const [tick, setTick] = useState(0);
const [resetKey, setResetKey] = useState(0);
return (
<div>
<button onClick={() => setTick((t) => t + 1)}>Parent tick: {tick}</button>
<ul>
<li>Parent tick value: {tick}</li>
<MemoChild label="stable" />
</ul>
<button onClick={() => setResetKey((k) => k + 1)}>New draft (remount via key)</button>
<Draft key={resetKey} resetKey={resetKey} />
</div>
);
}
Babel 7.29.9 (jsdelivr) · React 19.3.0 dev build (esm.sh, pinned)
ComponentRendersLast commit (ms)
Click Run — commits will appear here.

Click Parent tick a few times: the table’s App row climbs, but MemoChild’s row freezes at 1 — its only prop never changes, so React.memo skips calling it at all (the section above, proven with a test). Then click New draft (remount via key): Draft’s row climbs by exactly 1 per click and whatever you typed disappears each time, because a changing key forces a real unmount + remount instead of an in-place update — the same “reset switch” proven just above.

StrictMode’s dev-only double render and double effect

Section titled “StrictMode’s dev-only double render and double effect”

In development, <StrictMode> deliberately renders your component twice and runs effect setup → cleanup → setup once on mount. This is not a bug — it simulates what would happen if React needed to discard and reuse component state later (an optimization concurrent React relies on), so any render or effect that isn’t safely repeatable surfaces immediately, in development, instead of silently corrupting state in production.

src/__tests__/strictmode-double-render-effect.test.tsx
import { StrictMode, useEffect } from 'react';
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('StrictMode (dev) renders twice and runs setup -> cleanup -> setup once on mount', () => {
let renders = 0;
const effectLog: string[] = [];
function Probe() {
renders++;
useEffect(() => {
effectLog.push('setup');
return () => { effectLog.push('cleanup'); };
}, []);
return null;
}
render(
<StrictMode>
<Probe />
</StrictMode>,
);
expect(renders).toBe(2);
expect(effectLog).toEqual(['setup', 'cleanup', 'setup']);
});
test('without StrictMode, the same component renders and effects run exactly once', () => {
let renders = 0;
const effectLog: string[] = [];
function Probe() {
renders++;
useEffect(() => {
effectLog.push('setup');
return () => { effectLog.push('cleanup'); };
}, []);
return null;
}
render(<Probe />);
expect(renders).toBe(1);
expect(effectLog).toEqual(['setup']);
});
✓ StrictMode (dev) renders twice and runs setup -> cleanup -> setup once on mount
✓ without StrictMode, the same component renders and effects run exactly once
Test Files 1 passed (1)
Tests 2 passed (2)

If your render mutates something outside itself, or your effect’s cleanup doesn’t fully undo its setup, the second pass exposes it (wrong count, a duplicated subscription, a leaked timer) right away — that’s the entire point of the double-invoke, and it only happens in development.

Reconciliation’s whole job is answering one question per element, every render: “is this the same thing as before, or something new?” type answers it for a single position (same type = same thing, keep the instance; different type = something new, discard and rebuild). key answers it for identity that has to survive movement — a list item changing position, or a component you deliberately want treated as “a new thing” even though its type and props look similar. Once you see key and type as two inputs to the same “same or new” question, “key as list identity” and “key as a reset switch” stop looking like two different features.

ChoiceBenefitCost
Stable id as keyCorrect across reorder/insert/delete — state follows the right rowRequires a real stable id in your data; not always available for free
Index as keyZero extra data needed, “just works” for a list that never reordersSilently corrupts per-item state the moment the list CAN reorder, insert, or delete
key={changingToken} reset switchClean, guaranteed reset of an entire subtree’s state — cheaper than manually resetting every piece of stateFull unmount/remount is more expensive than an in-place update; effects re-run their setup entirely
StrictMode double render/effectSurfaces impure renders and unsafe effects in development, before they reach productionDoubles dev-mode render/effect cost (dev only — production runs once)
  • “Re-render always touches the DOM.” It doesn’t — render is pure tree-building; only the commit phase touches the DOM, and only for what actually changed (proven above: an identical re-render commits nothing).
  • “Index keys are fine as long as the list is short.” Length is irrelevant — the bug is state bleeding to the wrong row the moment the list reorders/inserts/deletes, proven above regardless of list size.
  • “key is only for lists.” It’s a general “same instance or new instance” signal — putting a changing key on a single, non-list component is a standard, deliberate way to force a full reset, proven above.
  • “StrictMode’s double render means my component runs twice in production too.” It’s development-only, proven above (the second test renders/effects exactly once with no StrictMode) — production always runs once.

When generated code renders a list or relies on component identity, check for these before trusting it:

  • Does .map() over a list use the array index as key for a list that can reorder, filter, or have items inserted/removed? That’s the classic state-bleed bug proven above.
  • Does it use Math.random() or Date.now() as a key? Both change every render, forcing a full remount of every item on every render — never a real fix for “React complains about missing keys.”
  • Does an editor/form component keep showing stale data after the “current item” prop changes, when a key={currentItemId} reset would cleanly fix it instead of manually resetting every piece of internal state?
  • Does a conditional render swap between different element/component types (e.g. isLoading ? <Spinner/> : <Form/>) where the author assumes state is preserved across the swap? Different types always remount — any state in the old subtree is gone.
  • Does test or demo code assume a component’s effect only runs once, without accounting for StrictMode’s dev-only double-invoke (which can make a naively-written effect appear to “double-fire” a network call or subscription)?

🐛 Spot the bug

function TodoList({ todos }) {
return (
<ul>
{todos.map((todo, i) => (
<li key={i}>
<input type="checkbox" defaultChecked={todo.done} />
{todo.text}
</li>
))}
</ul>
);
}
Show the answer

key={i} uses the array index as identity. As long as the list never reorders this looks fine — but the moment an item is inserted, removed, or the list is sorted, React matches the OLD checkbox state to whatever item now happens to sit at the same index, not to the item it was originally checked for. Proven above: checking the first row, then prepending a new item, leaves the checked state on position 0 (now a different todo) instead of following the todo the user actually checked. The fix is key={todo.id} (a stable id from the data), which keeps each checkbox’s state attached to the correct todo across any reorder.

What happens during the "render phase"?
During reconciliation, what does React do when an element's type is the SAME as before?
Why is using the array index as a key risky for a list that can reorder?
If a re-render produces the exact same output as before, how many DOM changes occur?
After clicking the button once (count becomes 1) and then re-rendering with the new key, but the SAME label, what does the button show?
function Draft({ label }: { label: string }) {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// rendered first as <Draft key="a" label="a" />, then as <Draft key="b" label="a" />
Mounted as <StrictMode><Probe /></StrictMode> in development, in what order do the logs print, and how many times does Probe render?
function Probe() {
  useEffect(() => {
    console.log('setup');
    return () => console.log('cleanup');
  }, []);
  return null;
}