FrameworksGENERALFRAMEWORK-SPECIFIC

Reconciliation and Keys

Deciding what changed between one UI state and the next comes down to identity: which node in the new list is the same thing as which node in the old one.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

How does a framework decide whether to update a node or replace it, and what does it use to tell items apart?

The user intent

Someone reorders a list of tasks by dragging one to the top, having already ticked two of them. They expect the ticks to travel with the tasks they belong to.

The obvious build

Give each list item its position as a key. Positions are unique, they are always available, and the linter stops complaining.

Why it breaks

Position is not identity. When the list reorders, the item at position 0 is a different thing than it was, but its key says otherwise — so the framework updates the existing node in place instead of moving it.

How it breaks in a real browser
  • Position is not identity. When the list reorders, the item at position 0 is a different thing than it was, but its key says otherwise — so the framework updates the existing node in place instead of moving it.
  • That is invisible for text-only rows and destructive for anything holding state. A checkbox, a text input, a collapsed section, a running animation and the focused element are all DOM state that stays with the node while the data moves past it.
  • Deleting the first of five rows shifts four keys. Every one of those rows is now matched to the previous row's node, so the framework updates four nodes to be one step out of step with their DOM state.
  • The corruption is data-shaped, so it survives your tests. Snapshot assertions compare rendered text, and the rendered text is correct — it is the checkbox nobody asserted on that is wrong.
  • It also breaks a11y in a way nobody logs: the focused input is now a different row's input, and the user typed the rest of their sentence into someone else's field (Focus Management).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Reconciliation answers one question: for each position in the new UI description, is there a corresponding node from the previous one that can be updated, or must a new one be created?
  • Without keys, correspondence is positional. The first child is compared with the first child. This is correct and cheap for lists that only ever grow at the end, and wrong for everything else.
  • Type is the first check. If the element type at a position changed, the old subtree is discarded and a new one is created — you cannot turn an input into a textarea by updating it, so it is replaced.
  • Keys replace position with identity. With keys, the previous children are indexed by key, and each new child is matched to the node with the same key regardless of where either sits (Hash Map in DSA is the index).
  • Matched means moved and updated; unmatched means created or destroyed. A key that appears in both lists produces a move plus a property update; one that appears only in the new list produces a mount; only in the old list, an unmount.
  • A key is scoped to its siblings, not to the document. It has to be unique and stable within the list, and it does not need to be — and should not be — anything globally meaningful.
  • Every framework in this module has this, under names including key and track. The mechanism differs in implementation and not at all in requirement (Reactivity Models).

What this makes the browser do

And which of it is avoidable.

  • A move is insertBefore on an existing node: cheap, and it preserves everything the node holds.
  • A replacement is node destruction plus construction, plus re-running whatever populated it, plus the loss of every piece of DOM state attached to it (What a Mutation Costs).
  • Correct keys convert an O(n) rewrite of a reordered list into a handful of moves. Wrong keys convert a handful of moves into a full rewrite, and both cost the browser accordingly.
  • Destroyed nodes take their event listeners, their layout and their paint with them, so a large keyed list rebuilding is a layout and paint event as well as a JavaScript one (The Cost of a Change).
  • Avoidable work: a key derived from a value that changes — an index, a timestamp, a randomly generated id on every render — which guarantees no match ever succeeds.

The decision, node by node

Reconciliation is a comparison of two trees where a full tree-difference algorithm would be too expensive to run on every interaction. Every framework here therefore uses the same set of heuristics, which are worth knowing because they are the rules your UI is being judged by.

The heuristics are: compare children at the same position; if the type changed, replace rather than update; and if the children are keyed, match by key instead of by position. That is essentially all of it, and the third rule is the one you control.

For each child position
  1. 1
    Is there a key?

    If yes, look up the previous child with the same key. If no, take the previous child at the same index.

    fails by An index used as a key looks like identity and behaves like position — the worst of both, because it silences the warning.

  2. 2
    Did the element type change?

    Same type means update this node in place. Different type means destroy the old subtree and build a new one.

    fails by A conditional that alternates element types at one position, rebuilding a subtree on every toggle.

  3. 3
    Update the node

    Set changed attributes, properties and text. The node — with its focus, selection, scroll and uncontrolled input values — survives.

    fails by Nothing, when it happens. This is the outcome you are keying for.

  4. 4
    Move if needed

    Reposition the matched node with an ordinary DOM insertion. Its state comes with it.

    fails by Only reachable with keys. Positional matching cannot express a move at all.

  5. 5
    Mount / unmount the remainder

    Keys present only in the new list are created; keys only in the old list are destroyed, with their state.

    fails by Unstable keys make everything unmatched, so every update is a full mount-and-unmount cycle.

Notice that "move" is only available in the keyed path. Without keys, a reorder can only be expressed as a series of in-place updates.

Watch the state travel

FRAMEWORK-SPECIFICWritten in React's JSX because a concrete example needs a concrete syntax. Vue and Svelte express the identical decision as a key on the list block, Angular as the required track expression, and Solid through its keyed list component — and the corruption is the same in all of them.

This is the concrete case, and it is worth walking through rather than accepting. Three tasks are rendered, each with a checkbox. The user ticks the second one. Then the first task is deleted.

With index keys, the framework is told that the item now at index 0 is the same item as the one previously at index 0. It is not — it is the item that used to be at index 1 — but the framework has no other information, so it updates the existing node's text and leaves everything else, including the checkbox, exactly where it was. The tick that belonged to "Ship release" is now sitting next to "Update docs".

The same list, two identity decisions
1// tasks: [{ id: 'a', label: 'Write spec' },
2// { id: 'b', label: 'Ship release' },
3// { id: 'c', label: 'Update docs' }]
4// The user ticks 'Ship release'. Then 'Write spec' is deleted.
5
6// WRONG — identity is position.
7{tasks.map((task, i) => (
8 <li key={i}>
9 <input type="checkbox" /> {/* uncontrolled: state lives in the DOM */}
10 {task.label}
11 </li>
12))}
13// After the delete:
14// key 0: node kept, text now 'Ship release' -> checkbox unticked (was 'Write spec')
15// key 1: node kept, text now 'Update docs' -> checkbox TICKED (was 'Ship release')
16// key 2: node destroyed
17// The tick moved from 'Ship release' to 'Update docs'.
18
19// RIGHT — identity comes from the data.
20{tasks.map((task) => (
21 <li key={task.id}>
22 <input type="checkbox" />
23 {task.label}
24 </li>
25))}
26// After the delete:
27// key 'a': not in the new list -> destroyed
28// key 'b': matched -> node moved up, tick comes with it
29// key 'c': matched -> node moved up, still unticked

The checkbox is deliberately uncontrolled, because that is where DOM state actually lives and where the corruption is easiest to see. The same thing happens to focus, to scroll position inside the row, to a running CSS transition, and to component state in a stateful child.

Identity mistakes, and what each one looks like

Keys go wrong in five recognisable ways. Being able to name the one you are looking at turns a mysterious UI bug into a one-line fix, which is unusually good value for a rule this small.

Five ways identity goes wrong
TriggerSymptomCauseResponse
Index used as key, list reorders or an item is deletedCheckbox, input value or focus attaches to the wrong rowPosition was matched to position; the data moved and the DOM did notKey by an id from the data. This is a correctness fix, not a performance one.
Key generated during renderEvery update destroys and rebuilds the whole list; focus and scroll are lost each timeNo key ever matches, so nothing can be reusedGenerate the id when the item is created and store it with the item.
Duplicate keys in one listItems silently disappear or render twiceThe key index has a collision and one entry winsMake the key unique within its sibling set — it need not be unique globally.
Key built from editable fieldsEditing a field rebuilds that row and drops focus mid-typingThe key was unique but not stable across editsKey by an immutable identifier, never by content the user can change.
Element type changes at a fixed positionA whole subtree is rebuilt on a toggle, with animations restartingType mismatch is checked before keys are consultedKeep the type stable and change attributes, or accept the rebuild knowingly.

How to build it

Most important first.

  • Key by identity that comes from the data: a database id, a stable client-generated id assigned when the item was created, or a natural key the domain guarantees is unique.
  • If items genuinely have no identity, generate one when the item enters the collection and store it with the item. Generating it during render defeats the entire mechanism.
  • Index keys are acceptable only when the list is static, or append-only, and contains no stateful children. Write down which of those you are relying on, because the next feature will break it.
  • Keep element types stable across updates. A conditional that switches between two element types at the same position forces a replacement even when the content is nearly identical.
  • When the list holds inputs, treat identity as a correctness requirement rather than a performance one — this is a data-integrity bug wearing a rendering costume (Controlled vs Uncontrolled Inputs).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Focus lives on a node. Destroying that node moves focus to the body with no announcement, and a keyboard user has to re-traverse the page to get back (Keyboard Operability).
  • Assistive technology tracks node identity too. Replacing nodes can restart a screen reader's reading position or cause it to re-announce content the user has already heard (The Accessibility Tree).
  • A list rebuilt inside a live region announces itself, sometimes entirely, which is a burst the user cannot follow (Live Regions and Announcement).
  • Text selection and IME composition are both DOM state: a user mid-composition in a CJK input loses their composition when the node is replaced.
  • Reordering with correct keys is still a change nobody hears. If the user initiated it, say so — the visual move is not an announcement (Live Regions and Announcement).

What can go wrong

Failure modes
  • Index keys plus reorder or delete: DOM state attaches to the wrong item. The characteristic failure of this lesson.
  • Random keys generated in render: nothing ever matches, so every update destroys and rebuilds the list, losing focus and scroll every time.
  • Non-unique keys within a list: behaviour becomes implementation-defined, and typically some items are silently dropped or duplicated.
  • Keys that are unique but not stable — a composite of fields that the user can edit — so editing a field moves an item to a "new" identity and its node is rebuilt.
  • Correct keys with an unstable element type above them, which discards the whole subtree before keys are ever consulted.
What can arrive out of order
  • A list re-keyed by a server-assigned id after an optimistic insert briefly holds a temporary id, then the real one. Unless the transition is handled, the node is destroyed and rebuilt at exactly the moment the user is looking at it (Optimistic UI).
  • Concurrent edits arriving from a live connection can reorder a list while a user is interacting with a row in it, which is a keying problem and a UX problem at once (Ordering and Duplicate Delivery).
Security
  • Keys are not a security mechanism. Using a value like an email address as a key exposes it in devtools and sometimes in generated attributes, for no benefit over an opaque id.
  • Reused nodes can carry state across a data change, and if the data belonged to different users — a list rebuilt after switching accounts — that is disclosure by way of a rendering bug (What the Frontend Is Responsible For in Auth).
  • Clearing state on identity change is a defensive habit: when the underlying entity changes, do not let the previous entity's DOM state survive into it.
Misreads
  • "Keys are a performance optimisation." They are a correctness mechanism that also happens to be faster. The state corruption is the reason to care.
  • "The warning is about performance." The warning is that the framework has no way to tell your items apart and is about to guess.
  • "Index keys are fine for a static list." They are, until the list stops being static — which is a property of a future commit, not of the current one.
  • "A unique key is a stable key." Unique means no collisions now; stable means the same item carries the same key across renders. Both are required, and only the first one gets linted.
  • "My tests would catch it." Most tests assert rendered text, and the rendered text is correct. The bug is in DOM state nobody asserted on (Component Testing).

Measuring it, and what changes in the field

How you would see this
  • The Elements panel with DOM mutation highlighting shows the truth immediately: reorder the list and watch whether nodes move or flash as re-created (Debugging Rendering and Jank).
  • The framework profiler shows mounts versus updates. A reorder that produces mounts is a key problem, whatever the render timings say.
  • The reliable manual test: put a checkbox and a text input in every row, type in one, tick another, then reorder. If either travels to the wrong row, the keys are wrong (Component Testing).
Slow device, slow network, large data, old tab
  • On a large list, wrong keys turn a cheap reorder into a full rebuild, and the cost scales with list length (List Virtualization).
  • On a slow device, that rebuild is a long task with a visible pause, where the correct version would have been imperceptible (Long Tasks).
  • Under a virtualised list, items enter and leave the DOM constantly, so key stability is load-bearing rather than incidental.
  • With drag-and-drop, reordering is the primary interaction, which is exactly the case index keys handle worst.
What this costs
  • Stable identity has to come from somewhere. If your API does not return ids, you now own generating and persisting them, which is real work that a positional key appears to avoid.
  • Keys constrain how you model collections: an item is a thing with an identity, not a position in an array. That is a better model and it is a change to make.
  • Keying correctly costs a little memory for the index the framework builds, and saves the entire cost of rebuilding — a trade that is almost always worth taking and is still a trade.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALEvery framework in this module needs a stable identity for list children, because matching by position is the only alternative and position is not identity. The spelling differs — a key prop in React, Vue and Svelte, a track expression in Angular, a keyed list component in Solid — and the requirement does not.
  • FRAMEWORK-SPECIFICWhat happens when identity is wrong differs in detail: React unmounts and remounts the component, losing its state as well as its DOM state; Solid and Svelte rebuild the generated block; Angular destroys and re-creates the view. The user-visible symptom — DOM state attached to the wrong item — is identical in all of them.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Concurrencydeterminism
Domains that do not exist yet
  • Testing & Reliability Engineering — this bug class is why a component test that asserts only on rendered text is insufficient: the text is right and the state is wrong, so the assertion has to be about interaction outcomes.