Keys in Lists
Why React keys exist — identity across reconciliations, index key bugs, remounting with key, and how to choose stable ids.
- react
- keys
- lists
- reconciliation
React’s list API looks like “map to elements.” The hard part is identity: when the array changes, which old fiber matches which new element? That match is driven by key among sibling elements of the same parent.
Keys are not a performance prop you sprinkle for good luck. They are how reconciliation decides update vs remount for list children. Wrong keys produce glitched inputs, state that teleports between rows, and “impossible” UI bugs.
Core model of the tree: Reconciliation & the Virtual DOM.
The problem keys solve
function TodoList({ items }) {
return (
<ul>
{items.map((item) => (
<li>
<input defaultValue={item.text} />
</li>
))}
</ul>
);
}
Without keys, React falls back to index among siblings. Insert at the front, reorder, or delete in the middle, and components reuse the wrong DOM nodes and local state. With a stable item.id, each row keeps its own fiber.
{items.map((item) => (
<li key={item.id}>
<input defaultValue={item.text} />
</li>
))}
Model: type + key + position
On the same parent, React matches children roughly by:
- key (if present)
- type (
divvsRowvsspan) - position heuristics when keys are missing
Same type + same key → update that fiber (props change, state preserved).
Different type or intentional key change → unmount old, mount new (state resets).
// Reorder with stable keys — state follows the item id
const items = [
{ id: 'a', text: 'One' },
{ id: 'b', text: 'Two' },
];
Swap a and b in the array: the fiber for a moves with a. Index keys would keep “position 0’s state” on the new first row — which might now be b.
Index as key — when it bites
// Fragile when list mutates mid-array
{items.map((item, index) => (
<Row key={index} item={item} />
))}
| List change | Index keys | Stable id keys |
|---|---|---|
| Append only | Often OK | OK |
| Prepend / insert middle | State shifts to wrong rows | State stays with id |
| Reorder / sort | State sticks to positions | State follows items |
| Delete middle | Later rows inherit earlier state | Correct unmount |
Static lists that never reorder (e.g. fixed two tabs with constant order) can use index without drama. As soon as the list is user-editable, index keys are a bug farm.
Local state is tied to the fiber, not your data row
function Row({ item }) {
const [checked, setChecked] = useState(false);
return (
<label>
<input
type="checkbox"
checked={checked}
onChange={() => setChecked((c) => !c)}
/>
{item.label}
</label>
);
}
function List({ items }) {
return items.map((item, i) => <Row key={i} item={item} />);
}
Check row 0, then prepend an item with index keys: the checked box appears on the new row 0. With key={item.id}, the checked state stays on the original item’s fiber.
If the checked flag is product state, store it in data (or a parent map keyed by id) — don’t rely on anonymous local state for source of truth that must survive list edits.
Keys must be stable, unique among siblings
Rules that hold in code review:
- Stable — same item → same key across renders.
- Unique among siblings — not globally unique across the whole app, but no duplicates under one parent.
- Do not use random keys (
key={Math.random()}) — remounts every render. - Do not use array index when order/length changes.
- Prefer business ids from the server or a client-generated id at creation time.
// Bad: new key every render
<Row key={Math.random()} />
// Bad: key derived from changing display text only
<Row key={item.label} /> // labels collide; renames remount
// Good
<Row key={item.id} />
Using key to reset state on purpose
Changing key is a supported way to remount a subtree when identity should start fresh:
function UserEditor({ userId }) {
// Changing userId remounts the form — no stale fields from previous user
return <ProfileForm key={userId} userId={userId} />;
}
Same idea for wizard steps, selected conversation threads, or “create new” vs “edit id”. This is deliberate identity change, not a list bug. See also resetting patterns under related articles like Keys and Remounting State when present.
Fragments and keys
When a map must return multiple siblings, key the Fragment:
import { Fragment } from 'react';
{items.map((item) => (
<Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</Fragment>
))}
Short syntax <>...</> cannot take a key. Use <Fragment key={...}>.
Keys are not props
function Row({ id }) {
// `key` is NOT available here as props.key
return <div data-id={id} />;
}
// Parent:
<Row key={item.id} id={item.id} />
React consumes key (and ref) specially. Pass id explicitly if the child needs the identifier.
Nested lists
Keys only need uniqueness among siblings. Nested lists can reuse the same key strings under different parents:
sections.map((section) => (
<section key={section.id}>
{section.rows.map((row) => (
<Row key={row.id} row={row} />
))}
</section>
))}
Still avoid index at every level if those arrays mutate.
Animation libraries and keys
Enter/exit animations often key by id so the reconciler and FLIP libraries agree on identity. Changing a key mid-animation remounts — which can be what you want (replay) or a flicker (accidental).
Footguns
- Index keys + controlled inputs — classic “typing jumps between rows.”
- Using index and claiming “we don’t reorder” until product adds sort six months later.
- Composite keys from unstable data (
key={item.updatedAt}). - Forcing remount with random keys to “fix” state bugs — hides the real state ownership problem.
- Duplicate keys — React warns; matching becomes undefined territory.
- Putting key on the wrong element — key must be on the outermost element returned from the map callback.
Interview angle
Prompt: “Why do we need keys in lists?”
Strong answer: “Reconciliation matches list children across renders. Keys give stable identity so React can move/update the correct fiber and preserve state. Index keys couple identity to position, so inserts and reorders attach state to the wrong items. Keys should be stable and unique among siblings; changing a key intentionally remounts and resets state.”
Follow-ups: difference between re-render and remount; how keys interact with memo; why virtualization still needs stable ids.
Related on this site
- Reconciliation & the Virtual DOM — where keys plug in
- Rules of Hooks — remount resets hook state
- Props vs State — where row state should live
- useMemo and useCallback — don’t “fix” list bugs with memo
- Preserving State on Reorder — identity deep dive
- Resetting State with key — intentional remounts
Further reading
- Rendering Lists — react.dev
- You Might Not Need an Effect — reset with key
- Reconciliation — legacy react docs (still conceptual)
Related guides
- Reconciliation & the Virtual DOMWhat re-render actually means, why keys exist, and when memo is a tool — not a personality trait.
- Accessibility Patterns in ReactPractical React a11y: labels, focus management, keyboard, live regions, and composition patterns that stay accessible.
- Avoid Prop Drilling with CompositionStop threading props through intermediates: children slots, inversion of control, and when context is the right escape hatch.
- Batching State UpdatesHow React 18+ batches setState in events, timeouts, and promises: when updates flush and why double setState still works.
- Children Prop PatternsUsing children and slot props for flexible APIs: wrappers, compound components, and when to prefer explicit props.