React keys, and the identity they give an element
Delete a row from a list of inputs and the wrong one goes blank — React reused a DOM node it thought was the same element. A key is what tells it otherwise: what it is, the two-pass algorithm that reads it, and the one-line fix.
The issue
"Each child in a list should have a unique key prop" is the warning every React developer has silenced with key={index} at least once. Weeks later, someone deletes a row and the wrong one's input goes blank. Nothing in that row's code changed — the bug is the key, and it comes down to what React actually does with it.
Not a DOM attribute — fiber metadata
<Row key={id} data={item} /> compiles to jsx(Row, { key: id, data: item }), and React strips key off that config object right there, storing it on the element and later the fiber. It never reaches props — inside Row, props.key is always undefined. It's metadata for React's own diffing, not a prop for your component.
Where it's read: render, not commit
React's work splits into a render phase — call components, build the new element tree, diff it against the last committed fiber tree; pure, interruptible — and a commit phase that applies the resulting DOM mutations synchronously. A key is read only inside the render phase, during the diff: it decides whether a new element reuses an old fiber (same DOM node, same state) or gets mounted fresh. Commit just executes whatever that decision already produced.
The two-pass algorithm
Full tree diffing is O(n³) in the general case, so React doesn't attempt it — it only compares siblings at the same level, and treats a type change as unmount-and-remount rather than diffing across it. For one array of children, reconcileChildrenArray runs this in two passes:
// Pass 1 — walk old and new together by index, while keys match.
let i = 0;
for (; i < newChildren.length; i++) {
const oldFiber = oldFiberAt(i);
if (!oldFiber || oldFiber.key !== newChildren[i].key) break; // first mismatch
if (oldFiber.type !== newChildren[i].type) {
deleteRemainingOldFibers(i);
break;
}
reuseFiber(oldFiber, newChildren[i]); // same key + type -> clone in place
}
if (i === newChildren.length) return deleteRemainingOldFibers(i); // list shrank
if (!oldFiberAt(i)) return mountRemaining(newChildren, i); // pure append
// Pass 2 — order changed: map what's left of the old list by key.
const existingChildren = mapRemainingOldFibersByKey();
for (; i < newChildren.length; i++) {
const match = existingChildren.get(newChildren[i].key ?? i);
if (match && match.type === newChildren[i].type) {
existingChildren.delete(newChildren[i].key ?? i);
reuseFiber(match, newChildren[i]); // found by key -> reuse, maybe move
} else {
mountNew(newChildren[i]); // no match -> fresh mount
}
}
deleteAllRemaining(existingChildren); // never claimed -> unmountPass one is one linear walk, no allocation — the common case. Pass two only runs once a mismatch proves something moved, and it's still one hash-map pass over what's left: build it once, look each new child up by key. Both passes are O(n) — nothing here compares every new child against every old one.
Index as key: the failure mode
Key a row by its index and reconciliation degrades to pure position matching — the key stops meaning "this item" and starts meaning "whatever sits here now". Harmless for a list that never reorders; a bug the moment one row can be inserted, removed, or moved.
// Row renders <input defaultValue={label} /> — uncontrolled,
// so the DOM node is the source of truth for what's typed.
// Before: [{id:"a",label:"Alice"}, {id:"b",label:"Bob"}, {id:"c",label:"Carol"}] keys 0,1,2
// After delete Alice: [{id:"b",label:"Bob"}, {id:"c",label:"Carol"}] keys STILL 0,1
// key 0: Bob now renders into Alice's old <input> node — REUSED, not recreated.
// key 1: Carol now renders into Bob's old <input> node — REUSED, not recreated.
// Whatever Bob or Carol had typed is now sitting under the wrong name.Key by item.id instead and the deletion becomes unambiguous: the fiber for "a" isn't in the new list, so only it gets deleted — "b" and "c" keep their own nodes and state, wherever they now sit.
Forcing a remount, on purpose
The flip side is a real pattern: change an element's key deliberately and React tears the old instance down and mounts a fresh one — a clean way to reset internal state when an id changes.
// ProfileForm's draft state should not carry over between users.
<ProfileForm key={activeUserId} userId={activeUserId} />
// New activeUserId -> new key -> React unmounts the old instance
// (state, effects, DOM) and mounts a new one. No manual reset needed.Do that by accident — key={Math.random()} on every render — and every render remounts the subtree, discarding state and focus. Same mechanism, opposite direction: index keys give an element too little identity; a fresh random key every render gives it none at all.