A React list is rendered as `rows.map((row, i) => <Row key={i} row={row} />)` and each Row contains an uncontrolled <input>. After the user deletes the first row, the remaining rows show the wrong typed text. What causes this, and what is the fix?
answer
- indices renumber, keys should not
- position identity vs item identity
- React reused the node, not the data
- state React does not control stays put
- uncontrolled input, focus, animation
basics
~20 sIndex keys make position the identity. Deleting the first row shifts every later row down one index, so React sees the same keys 0..n-1 and reuses each existing Row and DOM node for a different item, leaving the old input text behind. Key by a stable item id instead.
solid answer
~50 sWith `key={i}`, the keys after a deletion are still `0, 1, 2, …` — only the data behind them shifted. React pairs children by key, so it concludes that every row still exists and simply got new props; it reuses the same component instances and the same DOM nodes and just updates the text. Anything React does not control stays put: the value typed into an uncontrolled `<input>`, focus, scroll position, an in-flight CSS transition, and any `useState` inside `Row`. The visible symptom is that row 1's typed text is now sitting next to row 2's data. Switching to `key={row.id}` gives each row an identity independent of position, so React sees that the deleted row's key is gone, unmounts exactly that instance and its DOM subtree, and leaves the survivors paired with their own state.
code
jsx · 17 linesimport { useState } from "react";
export function NoteList({ initial }) {
const [rows, setRows] = useState(initial);
const remove = (id) => setRows((rs) => rs.filter((r) => r.id !== id));
return (
<ul>
{rows.map((row) => (
<li key={row.id}>
<input defaultValue={row.note} />
<button onClick={() => remove(row.id)}>delete</button>
</li>
))}
</ul>
);
}go deeper
Recognise the smell: an index key plus a list that can shrink or reorder. Say that the fix is to key by an id that belongs to the item.
Walk through the renumbering concretely — keys 0,1,2 become 0,1 — and name what stays behind: uncontrolled input values, focus, scroll and any state inside the row component.
Show how you would triage it in a real app: prove the data is correct, notice that only React-uncontrolled state is misplaced, and enumerate the sibling symptoms (animation on the wrong node, a row-level fetch that never re-runs).
Argue for making this unrepresentable — ids assigned where data enters the system, a lint rule against index keys in dynamic lists, and code review that treats "no natural id" as a data-modelling problem to fix upstream.
## The setup ```jsx function RowList({ rows, onDelete }) { return rows.map((row, i) => ( <Row key={i} row={row} onDelete={() => onDelete(row.id)} /> )); } function Row({ row, onDelete }) { return ( <li> <input defaultValue={row.note} /> <button onClick={onDelete}>delete</button> </li> ); } ``` The user types "call back on Monday" into the first row's input, then deletes that row. ## Why the text survives on the wrong row Before the delete the children carry keys `0, 1, 2`. After the delete the array is one shorter and `map` numbers it from zero again, so the children carry keys `0, 1`. From React's point of view keys `0` and `1` were present before and are present now — nothing was removed, the last child just disappeared and the surviving ones got new props. So React does not unmount anything except the final position. It reuses the component instance and the DOM node that used to be row 0 for what is now the second item's data, and merely updates the props. That distinction matters because a lot of state does not live in props: - **Uncontrolled DOM state** — `input.value`, `checked`, `<textarea>` content, `<details>` open state, `<video>` playback position. React set these once at mount via `defaultValue`/`defaultChecked` and never writes them again, so the browser keeps whatever the user typed. - **Component state** — any `useState` or `useReducer` inside `Row`: an "editing" flag, a draft value, an expanded/collapsed toggle. - **Ambient DOM state** — focus, text selection, scroll offset inside the row, a running CSS transition or animation. All of that is attached to the *instance and node*, which React just re-pointed at a different item. The data moved up one slot; the state stayed where it was. Hence the mismatch. ## Why a stable key fixes it ```jsx rows.map((row) => <Row key={row.id} row={row} onDelete={...} />) ``` Now the keys before the delete are `a, b, c` and afterwards `b, c`. React sees that key `a` is present in the old children and absent in the new ones: that item genuinely went away, so it unmounts that `Row` and destroys its DOM subtree — taking the typed text with it. Keys `b` and `c` are still there, so their instances, their inputs and their internal state stay paired with their own data. Reordering behaves the same way: the identity travels with the item, so React moves the existing node rather than rewriting the contents of the node that happens to sit in that slot. ## Recognising the family of bugs Index keys produce a recognisable set of symptoms, all with the same root cause: - typed text, checkbox ticks or focus "stuck" on the wrong row after an insert, delete or sort; - an expanded/selected row jumping to a neighbour when the list is filtered; - enter/exit animations firing on the wrong element, or not firing at all, because the node was reused rather than mounted; - a per-row component that fetches on mount not refetching, because it was never remounted for the new item; - deletions that look like "the wrong item was deleted" when the data is actually correct and only the retained state is wrong. A useful diagnostic: if the *data* is right (log the array) but the *screen* is wrong, and the wrongness is limited to things React does not own — input values, focus, local component state — you are looking at an identity bug, not a data bug. ## When the index is genuinely harmless Index keys cause no problem when all of the following hold: the list is never reordered, filtered, or inserted into anywhere but the end; items are never removed from the middle; and the rows hold no state of their own, uncontrolled or otherwise. A static footer nav rendered from a constant array is fine. That is a narrow contract, and it silently stops holding the day someone adds sorting — which is why a stable id is the default and the index is the exception you justify. ## The related trap: controlled inputs mask it If the input is controlled (`value={row.note}` plus an `onChange`), the wrong-text symptom disappears, because React overwrites `value` from props on every render. The identity bug is still there — focus, scroll, animation and internal `useState` still land on the wrong row — it is just harder to see. Do not read "the text looks right" as evidence that index keys are safe here.
- If the inputs were controlled with value and onChange, would the bug disappear?The visible text symptom would, because React rewrites `value` from props on every render. The identity bug remains: focus, text selection, scroll position, running animations and any `useState` inside the row still stay attached to the reused instance and end up on the wrong item. Controlled inputs hide one symptom, they do not fix the cause.
- Appending to the end of the list with index keys seems to work fine — why?Because appending is the one mutation that does not renumber anything: existing items keep their indices and only a new, higher index appears, which React mounts fresh. The contract holds until someone adds sorting, filtering, or deletion from the middle, and then it breaks quietly rather than loudly — which is why the index is not a safe default.
- How would you confirm in the browser that this is a key problem rather than bad data?Log or inspect the array feeding the list: if the items and their order are correct while the screen is wrong, the data layer is fine. Then check what exactly is wrong — typed values, focus, an open/expanded flag. Those are the things React does not rewrite from props, so their being attached to the wrong row points straight at identity.
saying these in an interview costs you the question
- React deleted the wrong item from the array.
- Index keys are fine as long as items are unique.
- Adding a key to the inner element fixes it.
- Wrapping the row in React.memo would solve it.
- Controlled inputs make index keys safe.