skip to content

A React list keyed by array index has a new item prepended. Trace what the reconciler does: which fibers are reused, which DOM nodes are created or moved, and where each row's hook state ends up? Contrast that with the same list keyed by a stable item id.

level: seniorimportance: must knowfreq 55%

answer

  1. compare the two key sequences first
  2. index keys keep matching at every slot
  3. data shifts, fibers do not
  4. one created node vs one inserted node
  5. state belongs to the fiber that stayed

basics

~20 s

With index keys every slot still matches, so React reuses all fibers and patches each one with the next item's data, appending one new fiber at the end. Hook state stays with the position, not the item. Stable ids instead insert one node and leave the rest untouched.

solid answer

~50 s

Index keys make the keys `0..n-1` become `0..n`, so the parallel walk succeeds at every position. React reuses the fiber at slot 0 but feeds it the *new* first item's props, reuses slot 1 with what used to be item 0, and so on down the list; one extra fiber is created for the last index. So no DOM node moves — n nodes get their text and attributes patched and one is created. Since hook state, refs and uncontrolled DOM values live on the fiber that stayed put, they stay bound to the position while the data shifts past them. With stable ids the walk breaks at index 0, the remaining previous children go into a key map, every existing row is found there and left where it is, and only the genuinely new element is created and inserted at the front. One DOM insertion, no patched rows, and every row's state stays with its own item.

code

javascript · 11 lines
javascript
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const afterPrepend = [{ id: 'x' }, ...rows];

const indexKeysBefore = rows.map((row, i) => String(i));
const indexKeysAfter = afterPrepend.map((row, i) => String(i));

const idKeysBefore = rows.map((row) => row.id);
const idKeysAfter = afterPrepend.map((row) => row.id);

console.log(indexKeysBefore, '->', indexKeysAfter);
console.log(idKeysBefore, '->', idKeysAfter);

go deeper

for a junior

Know that array-index keys break when items are inserted, removed or reordered, and that a stable id from the data avoids it. Say that with index keys the key no longer identifies the same item.

for a middle

Trace the mechanism: index keys keep matching at every slot, so fibers are reused with shifted props and patched in place, while a stable id makes the walk break and the key map reunite each row with its own fiber.

for a senior

Quantify it and name the consequence: n patched rows plus one creation versus a single insertion, and state binding to position rather than to the item — then tie it to a real bug you diagnosed, such as an input's value staying behind on the wrong row.

for a principal

Set the policy angle: where item identity comes from across the codebase, whether a lint rule banning index keys is worth its false positives, and when append-only lists justify the exception.

## Setting up the trace Start with three items `a, b, c` and prepend `x`. With `key={index}`, the previous children carried keys `0, 1, 2` and the new children carry `0, 1, 2, 3`. With `key={item.id}` the previous keys were `a, b, c` and the new ones are `x, a, b, c`. ## Index keys: everything matches, everything shifts The parallel walk compares key `0` with key `0` — a match — then `1` with `1`, then `2` with `2`. It never fails, so the key map is never built. - **Slot 0**: the fiber that was rendering `a` is reused, but the element at index 0 now describes `x`. React applies the new props to that fiber, so the component re-renders and its host DOM node is patched — text content and any changed attributes are written to the existing node. - **Slots 1 and 2**: same story, now rendering `a` and `b` respectively. Both are patched. - **Slot 3**: no previous child, so a new fiber is created and its DOM node appended. Totals: 3 fibers reused and re-rendered, 3 DOM nodes mutated, 1 node created, 0 nodes moved. Now the important part — where state ended up. Hook state (`useState`, `useReducer`, `useRef`), the ref attached to the host node, and everything the DOM node itself owns (an uncontrolled input's value, focus, selection, scroll offset) all belong to the *fiber*, and each fiber stayed at its position. The data moved down one slot; the state did not move at all. The state that row `a` accumulated now sits on the row rendering `x`. This also means React did roughly the maximum amount of work for a minimal change: a one-item insertion caused every row in the list to re-render and every row's DOM to be patched. ## Stable ids: one insertion, nothing else The parallel walk compares the new key `x` with the previous key `a` and fails immediately at index 0. React then collects the remaining previous children — `a`, `b`, `c` — into a map keyed by key. - `x` is looked up, misses, and a new fiber is created and marked for placement. - `a` is looked up and hits. React tracks how far into the previous list it has already placed things; because `a` came from position 0 and nothing later has been placed yet, it is *not* flagged as a move. - `b` and `c` hit the same way, in increasing previous-position order, so neither is flagged either. - The map is empty at the end, so nothing is deleted. Totals: 3 fibers reused with unchanged props, 1 fiber created, 1 DOM node inserted at the front, 0 nodes moved, 0 nodes patched. The reused rows still re-render as functions if they are not memoised, but they produce identical host output, so the commit writes nothing to their DOM. And every row's hook state, refs, focus and uncontrolled values stay with the row's own data, because each item kept its own fiber. ## Deletion and reorder follow the same logic Delete the first item with index keys and the keys go `0,1,2` to `0,1`: slot 0 is patched to render the old item 1, slot 1 is patched to render the old item 2, and the *last* fiber is deleted. The DOM node that disappears is the last one, even though the item that disappeared was the first. Reorder a list with index keys and nothing is flagged as a move at all — React simply rewrites every row's contents in place. ## Why no-key behaves identically When children have no key at all, pairing falls back to position, which is precisely what index keys encode. So `key={index}` and no key produce the same reconciliation; the only difference is the development warning. ## When index keys cost nothing If positions really are identities — an append-only list that is never filtered, sorted or spliced, whose rows hold no local state, no refs and no uncontrolled inputs — the two strategies produce the same work, because item *i* is always the same item. The moment an insertion, deletion, filter or sort can happen, position stops being identity and everything above follows. ## Answering well Give the counts (n patched plus 1 created, versus 1 inserted), then state the rule that makes it memorable: with index keys, state belongs to the position and the data shifts past it; with stable ids, state belongs to the item and travels with it.

  • With index keys, which DOM node is actually removed when the first item is deleted?
    The last one. The keys shrink from `0,1,2` to `0,1`, so slots 0 and 1 are reused and patched with the shifted data, and the fiber for the now-missing index 2 is deleted. The node that leaves the document is the final row, even though the item that left was the first.
  • Is keying by index ever the same as passing no key at all?
    Yes — pairing without keys is positional, which is exactly what index keys encode, so the reconciliation is identical. The only difference is that the unkeyed version triggers React's development warning about list children. Neither carries any identity beyond position.
  • With stable ids, does prepending cause React to move the existing DOM nodes?
    No. React tracks how far into the previous child list it has already placed things; the reused rows are found in increasing previous-position order, so none is flagged as a move. The commit performs a single insertion of the new row's node in front, and touches nothing else.
  • When are index keys genuinely harmless?
    When position is identity: an append-only list that is never sorted, filtered, spliced or reordered, whose rows hold no `useState`, no refs and no uncontrolled inputs. Under those conditions item *i* is always the same item, so positional pairing is correct. Any insertion or filter breaks the assumption.

saying these in an interview costs you the question

  • Index keys make React recreate the whole list
  • With index keys the DOM nodes get reordered
  • State follows the item because the props moved with it
  • Index keys only matter when rows have state
  • Stable ids force React to move every DOM node

context