ML
React

Reconciliation & Keys: Why Index-as-Key Bites You

Keys identify siblings across renders; using the array index as a key silently attaches state and DOM to the wrong row on insert, reorder, or delete.

May 12, 202610 min readReactReconciliation

React's reconciler decides, for every render, whether to update an existing DOM node and component instance or tear it down and build a new one. Keys are the only signal you give it to match elements across renders, and getting them wrong is how local state, focus, and uncontrolled input values end up glued to the wrong row.

1. How React diffs the tree

After a render produces a new element tree, React compares it against the previous one (the committed fiber tree) to compute the DOM mutations it needs to apply. A general tree diff is O(n³), so React uses two heuristics that hold for almost all UIs:

  • Different element type at the same position → remount. React unmounts the old subtree (running cleanup, discarding state) and mounts a fresh one. A <div> becoming a <span>, or <Profile> becoming <Settings>, is never reused.
  • Same type at the same position → update in place. React keeps the same DOM node and the same component instance (same fiber, same hook state), then diffs props and patches only what changed. Children are recursed into.

The key phrase for interviews: React reconciles by position within a parent. Position means the element type plus, for siblings, the key. Without keys, position is literally the array index — which is exactly where the trouble starts.

// Type DIFFERS at this position (bare <input> vs. an <input>
// wrapped in a <div>), so React remounts: the input node is
// destroyed and recreated, and uncontrolled text is lost.
{isError
  ? <div className="err"><input value={name} readOnly /></div>
  : <input defaultValue={name} />}

// Same type at the same position (always a bare <input>), so React
// updates in place and the DOM node — and its text — persists:
<input defaultValue={name} readOnly={isError} />

2. Why keys identify siblings across renders

Within a list of siblings, React cannot rely on position alone — items move, get inserted, and get removed. The key prop is a stable identity that lets React match an element in the new render to an instance from the previous render regardless of where it now sits in the array.

Think of it this way: the key answers "which of last render's siblings is this the same one as?" If keys match, React updates that instance in place and, if needed, moves its DOM node. If a key is new, React mounts a fresh instance. If a key disappeared, React unmounts the old one. Keys only need to be unique among siblings, not globally, and they are never passed to your component as a prop.

{rows.map((row) => (
  <Row key={row.id} data={row} />   // identity = row.id, position-independent
))}

3. The index-as-key bug

When you write key={index}, the key is no longer tied to the data — it's tied to the slot. Insert at the front, reorder, or delete from the middle, and every item below the change keeps the same key it had before while now rendering different data. React sees matching keys, concludes "same instance," and updates in place instead of moving. The visible content shifts because props change, but anything React tracks by instance does not move with it:

  • Component state (useState, useReducer) stays on the fiber at that index.
  • Uncontrolled DOM state: text typed into an uncontrolled <input>, checkbox checked-ness, scroll position, focus, even a playing <video>.
  • Mount and cleanup effects do not fire, because React never remounts — it treats the slot as the same instance. Effects with dependency arrays still re-run when their dependencies change, but a component that should have unmounted and remounted simply does not.

Concretely: you have a list with a checkbox per row. You check row 0, then prepend a new row. With index keys, the new row takes key=0 and inherits the checked DOM state; your original (now at index 1) loses it. The checkmark "stayed at the top" because the DOM node never moved — only the label text did.

4. A concrete reorder bug — and the fix

Each row holds local draft state. With index keys, reordering the list scrambles which draft belongs to which item:

function Row({ item }) {
  const [draft, setDraft] = useState(item.label);
  return (
    <li>
      <span>{item.label}</span>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
    </li>
  );
}

function List() {
  const [items, setItems] = useState([
    { id: "a", label: "Alice" },
    { id: "b", label: "Bob" },
    { id: "c", label: "Carol" },
  ]);

  const reverse = () => setItems((xs) => [...xs].reverse());

  return (
    <>
      <button onClick={reverse}>Reverse</button>
      <ul>
        {items.map((item, i) => (
          // BUG: key is the slot, not the item
          <Row key={i} item={item} />
        ))}
      </ul>
    </>
  );
}

Edit Alice's input to "Alice (me)", then click Reverse. The item prop at index 0 is now Carol, so the label flips to "Carol" — but the draft state lives on the fiber at key=0 and never moved. You see "Carol" next to an input still reading "Alice (me)". State and DOM desynced from the data because the key tracked position, not identity. (Note that draft is seeded from item.label only on mount; because there is no remount, it keeps its edited value rather than resetting to "Carol".)

The fix is one line of intent — key by stable identity:

{items.map((item) => (
  <Row key={item.id} item={item} />   // identity travels with the data
))}

Now reversing the array makes React match key="a" to the same fiber it had before and move that DOM node to its new position. The draft state rides along with Alice. The rule of thumb: the key must come from the data, not from the loop. If your data has no natural id, generate a stable one when you create the row (e.g. crypto.randomUUID() stored on the item), never derive it at render time.

5. When index keys are actually fine

Index keys are not always wrong; they're wrong when identity matters. They're acceptable when all three hold:

  • The list is static — items are never inserted, deleted, or reordered (append-only at the end is borderline; deletes from the middle break it).
  • Items have no local state and no uncontrolled DOM state — pure, fully-controlled presentation driven entirely by props.
  • Items have no stable id to use instead.

A read-only, never-reordered list of strings is the canonical safe case. The moment a row gains a useState, an uncontrolled input, or the list becomes mutable, switch to a data-derived key. And avoid key={Math.random()} as an escape hatch: a fresh key every render forces a remount of every row on every render — correctness by way of throwing away all reuse, plus lost focus and wrecked performance.

6. Key as a deliberate remount trick

The same mechanism that bites you is a precise tool when used on purpose. Because changing an element's key changes its identity, you can force a remount — discarding all internal state and re-running mount effects — by changing the key intentionally. This is the idiomatic way to reset state when a prop changes:

// Reset the whole form's internal state when the selected user changes,
// instead of syncing every field in an effect.
<UserForm key={userId} userId={userId} />

When userId changes, React sees a different key in the same position, unmounts the old UserForm (cleanup runs), and mounts a fresh one with clean state. This is React's own recommended alternative to "resetting state with an effect" — it's declarative and there's no intermediate render with stale state. Use it for things like a comment box that should clear when you switch threads, or a wizard step that should fully reset.

7. A note on Server Components

The same keying rules apply across the server/client boundary. A Server Component renders to the RSC payload and holds no state of its own, but any Client Components it renders do. When the client reconciles that payload, it still matches Client Component siblings by key, so index keys on a list that later reorders or mutates on the client will desync the client-side state, focus, and uncontrolled DOM exactly as in a pure client app. The boundary doesn't save you — key by identity on both sides.

8. A note on StrictMode

If you reach for index keys to debug a flicker or a state mix-up, be aware that React's <StrictMode> intentionally double-invokes component bodies, mount effects, and reducers in development only to surface impure renders and missing cleanup. That behavior is stripped in production builds and is unrelated to keying — don't confuse a StrictMode double-mount with a key-driven remount when diagnosing list bugs.

Rules of thumb

  • Same type + same position → update in place; different type → remount. Keys decide "same position" for siblings.
  • Key by stable data identity (item.id), not by array index and never by Math.random().
  • Index keys are tolerable only for static, stateless, id-less lists — and they stop being safe the instant the list mutates or a row holds state.
  • Symptoms of an index-key bug: typed input, checkbox, focus, or scroll "sticking" to a position while the data underneath moves.
  • Need to reset a component's state on a prop change? Change its key to force a clean remount — declarative and effect-free.
SharePostLinkedIn

Reader Discussion

1 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Rachel Gold· Staff SREAgrees

    the on-call framing throughout this piece is what makes it land. too many infra articles assume you never get paged. those are written by people who never got paged.

    May 15, 2026·3 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email