skip to content

When a list matched by position has a middle item removed, which per-item state ends up on the wrong row and why?

level: middleimportance: must knowfreq 76%

answer

  1. the count changed, the mapping did not
  2. inputs are refreshed, state is not
  3. the last instance is the one destroyed
  4. focus, caret, scroll and animation stay behind

basics

~20 s

Everything not recomputed from inputs: the row's own state, focus and caret, an uncontrolled field's text, scroll offset and running animations. Positional matching re-feeds each instance with the next item's data and destroys the last instance, not the removed one's.

solid answer

~50 s

Positional matching pairs slot 1 with slot 1, slot 2 with slot 2, and so on. Remove the second of four items and the third item's data now renders into the second item's instance, the fourth into the third's, and the leftover instance at the end is destroyed — so the state that disappears belongs to the *last* row, not the removed one. Each surviving instance gets refreshed inputs, so the visible text looks right; what does not move is everything the instance holds that inputs do not recompute — its own state, focus and text selection, an uncontrolled field's current value, scroll position, a running animation, pending work. The runtime cannot detect the removal, because position was the only identity it was given: a shorter list whose items all shifted and a list whose items all changed data are indistinguishable to it.

go deeper

for a junior

Learn the symptom by heart: after a middle removal, a tick or an open row appears on the neighbour and the last row's state is gone. Reach for a key from the data, not the index.

for a middle

Trace it out loud, slot by slot. Say which instance is destroyed (the last one), why the visible text still looks correct (inputs are refreshed), and which categories of state stay behind (own state, focus and caret, uncontrolled text, scroll, animations).

for a senior

Recognise it from a vague report — wrong row selected, focus jumping, a save on the wrong record — and reproduce it deliberately by mutating the middle of the list. Then check whether the list's rows own anything at all before spending effort on it.

for a principal

The interesting question is why the key was missing: usually the payload has no stable id. Decide where identity is minted — server, gateway, or at item creation — so lists stop inventing one from position.

## The trace, step by step Take a list rendered as `[A, B, C, D]`, one component instance per item, matched by position because no key was supplied. Each row holds something of its own: a checkbox, an open/closed toggle, a text field the framework does not control, or simply focus. Now `B` is removed, so the new description is `[A, C, D]`. | Slot | Old occupant | New data | What the runtime does | |---|---|---|---| | 1 | `A` | `A` | pairs them, refreshes inputs — correct | | 2 | `B` | `C` | pairs them: `B`'s instance now renders `C`, keeping `B`'s own state | | 3 | `C` | `D` | pairs them: `C`'s instance now renders `D`, keeping `C`'s own state | | 4 | `D` | — | no new child at this slot, so `D`'s instance is destroyed | The counter-intuitive part is the last line. Nothing belonging to `B` was thrown away; `B`'s *data* left, but its instance stayed and was handed `C`'s data. The instance destroyed is `D`'s — the one at the end. If a user had ticked `D`'s checkbox and typed into `D`'s field, that is what vanishes, while `B`'s tick appears to have moved down onto `C`. ## Why the inputs look right and the state does not A paired instance is updated, not rebuilt. Everything it renders **from its inputs** is recomputed, so the row's title, its badge and its formatted date all switch to the new item immediately — which is exactly why the bug is so hard to see. What is not recomputed stays: - **the instance's own state** — a checked box, an expanded section, an edit-in-progress flag, a locally sorted order; - **host-node state the runtime never wrote** — focus, the text caret and selection, an uncontrolled field's current text, a scroll offset inside the row, a media element's playback position; - **a running animation or transition** on that node, which continues from wherever it was; - **handles other code holds** to that node, and any observer attached to it; - **work the instance started** — an in-flight request or a timer whose result will land on the wrong item. So the symptom is not "state is lost". It is "state belongs to a position instead of an item": the wrong row looks selected, focus jumps to a neighbour mid-typing, an entry animation plays on a row that was already there, a save lands on the record beside the one the user edited. ## Why the runtime cannot simply notice This is the part interviewers push on. The runtime is given two lists of descriptions and, without keys, no identities. From its side, `[A, C, D]` after `[A, B, C, D]` is *indistinguishable* from a four-item list that became a three-item list whose every item changed its data. Both readings produce the same pairing, and the cheaper reading — update in place, drop the surplus at the end — is the one it takes. The information needed to prefer the other reading is precisely what a key supplies. This also explains why an **index as a key** changes nothing about the bug. The index *is* the position, so keys `0, 1, 2, 3` are compared against `0, 1, 2` and pair slot to slot exactly as before. Its one real effect is social: in a framework that warns on a missing key, an index silences the warning, converting a visible smell into a silent bug. ## How the symptom varies by mutation 1. **Append or remove at the end.** No misalignment: the shifted region is empty. Positional matching is correct here, which is why lists that only grow at the end can run for years without anyone noticing the missing key. 2. **Insert or remove in the middle, or prepend.** Every row from that point on is off by one, and one instance at the end is created or destroyed. This is the classic reproduction. 3. **Pure reorder.** The instance count never changes, so nothing is created or destroyed at all; each instance simply renders someone else's item with its own leftover state, and no animation or teardown gives the user a clue. 4. **Filtering.** The worst case in practice, because a filter reshapes the list on every keystroke and the misalignment shifts each time. ## When it is not a bug at all If a row holds nothing that must travel — no state of its own, no focusable or editable node, no scroll container, no animation, no handle held elsewhere — positional matching produces a correct screen. It is still doing more host work than a keyed list on a reorder, since it rewrites contents instead of moving nodes, but nothing is wrong. Judging a list by what its rows own, rather than applying a blanket rule, is what separates a memorised answer from an understood one. ## The fix Supply a key from the data — an id that belongs to the item and stays with it across updates, reorders and refetches. Then removing `B` destroys `B`'s instance, and `A`, `C` and `D` keep theirs along with everything attached to them.

  • When is matching a list by position harmless?
    When the rows own nothing that must travel: no state of their own, no focusable or editable node, no scroll container, no running animation, no handle held by other code. A static list of rendered text is fine. It still does more host work on a reorder, rewriting contents instead of moving nodes, but the screen is correct.
  • Why does using the array index as the key not fix it?
    Because the index is the position. Keys `0, 1, 2` are compared against `0, 1, 2` and pair slot to slot exactly as positional matching did. The only thing that changes is that a framework which warns about a missing key goes quiet, so an index key can turn a visible smell into a silent bug.
  • How does the symptom differ between a prepend and a pure reorder?
    A prepend shifts every row one slot and creates one instance at the end, so every row's state is off by one. A pure reorder changes no instance count: nothing is created or destroyed, each instance just renders another item's data with its own leftover state, and the user gets no mount or animation cue that anything happened.

saying these in an interview costs you the question

  • Says the removed row's own state is what disappears
  • Assumes refreshed inputs also reset a row's internal state
  • Thinks the runtime can detect a removal without identities
  • Believes an index key repairs what positional matching broke
  • Blames the data layer for a rendering identity bug
  • Claims every unkeyed list is broken regardless of its rows