← Writing
The Editorial · Engineering

React keys, and the identity they give an element

Issue 010Jul 16, 20265 min read

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.

jsx(Row, { key: id, data: item })config objectkey: iddata: itemstored on the fiberfiber.key = idpassed into the componentprops = { data: item }props.key is always undefined
Fig 1key is peeled off the config object onto the fiber, before props ever forms.AI-generated figure

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:

reconcileChildrenArray, simplifiedPseudocode
// 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 -> unmount
old fibers · previous renderabcdno match → unmountnew elements · this renderacbe+no match → mountpass 1pass 2 · matched by key, not slot
Fig 2Pass 1 matches by position while keys agree; the first mismatch hands off to pass 2's key→fiber map, which catches the move, the mount, and the unmount in one pass each.AI-generated figure

Pass 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.

deleting row 0 with index keysJSX
// 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.

resetting state by changing the keyJSX
// 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.

All issues