open atlas
↑ Back to track
React, zero to senior RCT · 01 · 02

Reconciliation and keys: where your state actually lives

React diffs by element type and key, not by your component instances: same type at the same position keeps the fiber and its state, a type change unmounts the subtree. Index-as-key pins state to positions, so a reorder silently hands one row's state to another.

RCT Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The bug report read like a ghost story: “I was typing a reply to my manager and the draft moved to my conversation with a candidate. I almost sent a salary discussion to the candidate.” The messenger’s sidebar listed conversations sorted by last activity, each row owning an uncontrolled draft input, rendered with key={index}. While the support engineer was typing in row 0, a new message arrived in another thread, the list re-sorted, and a different conversation became row 0. React did exactly what it was told: the element at position 0 had the same type and the same key as before, so it kept the existing fiber — and the existing fiber owned the input’s state. The draft text never “moved” anywhere. It stayed bolted to position 0 while the data underneath it slid away. No exception, no warning, no failed test — the diff was correct by React’s rules; the rules were just told that position equals identity. Three users hit it before anyone could reproduce it, because the repro required a background event landing mid-keystroke.

A true minimal diff between two trees costs O(n³) — for a thousand elements, on the order of a billion comparisons, dead on arrival for a UI that re-renders on keystroke. React refuses to play that game and instead bets on two heuristics that are almost always true in real UIs. First: two elements of different types produce different trees — if a position held a div and now holds a section, or held a UserProfile and now holds a LoginForm, React does not look inside. It unmounts the entire old subtree (destroying all state in it, running effect cleanups, removing DOM) and mounts the new one from scratch. Second: the developer can mark stable identity across renders with a key. With these two assumptions, reconciliation runs in O(n).

The comparison itself works position by position. Same type at the same position: React keeps the existing fiber, updates its props, and recurses into the children — the component instance survives, its hooks survive. Different type: full teardown of that subtree. This is also why the comparison is between element descriptions, never between component “instances” — elements are recreated as fresh objects on every render and carry no state at all. They are throwaway descriptions; the fiber is the persistent thing they get matched against.

State lives on the fiber, and the key is the matching rule

When you wonder why your input keeps its typed text across re-renders, the answer is: everything useState gives you is stored on the fiber, not in your JSX and not in the closure of your function — the function ran and returned; its locals are gone. So the question “where is my input’s text?” has a precise answer: on the fiber that React matched to your element during the last reconciliation. For a single child, matching is by position and type. For an array of siblings, position is meaningless — lists get reordered, filtered, prepended — so React matches siblings by key: the old fiber with key “a” pairs with the new element with key “a”, wherever it moved. A matched fiber keeps its state and its DOM node, and the move costs one DOM reposition instead of a teardown.

Now the failure mechanism from the Hook, precisely. With key={index}, the key is the position — you have re-stated the default and disabled the entire matching machinery. Reorder the data and React sees: position 0 still has key 0, same type → same fiber → same state. The fiber holding the manager-draft now receives the candidate-conversation’s props. Everything that lives on the fiber or its DOM node sticks to the position instead of following the data: uncontrolled input text, checkbox state, focus, scroll position, useState inside the row, even memoized values. Deleting the first item is the same bug in another costume: every fiber shifts to a new data item, and the last fiber gets unmounted — so “I deleted row one, but row two’s notes vanished and row one kept the deleted ticket’s notes.”

const [convos, setConvos] = useState(initialConvos);
// convos re-sorts when any thread gets a new message

// Bug: key restates the position. After a re-sort, the fiber (and the
// uncontrolled input state on it) at position 0 is matched to whatever
// conversation now sits at position 0.
{convos.map((c, index) => (
  <ConversationRow key={index} convo={c} />
))}

// Fix: key = stable identity from the data. The fiber follows the
// conversation wherever it moves; React repositions the DOM node.
{convos.map((c) => (
  <ConversationRow key={c.id} convo={c} />
))}

Two corollaries seniors get asked in review. key={Math.random()} is the opposite bug and strictly worse: no key ever matches, so every render unmounts and remounts every row — state wiped each render, effects re-run, full DOM rebuild; a 200-row table goes from a handful of patched nodes to 200 teardown-and-create cycles per keystroke. And keys only need to be unique among siblings, not globally — item.id is fine even if another list elsewhere uses the same ids.

Why this works

Why did the React team not just diff by component instance identity and skip keys entirely? Because there is no instance identity to diff by. Your component function runs and returns plain element objects; convos.map produces a brand-new array of brand-new objects every render. Nothing in JavaScript connects “the second element of last render’s array” to “the second element of this render’s array” except what React can read off the objects: type, position, key. Keys are the developer lending React the one fact it cannot infer — which description refers to the same logical entity as before. That is also why the key warning is a warning and not an error: index matching is genuinely fine for a list that never reorders, never filters, and holds no state — a static footer-link list loses nothing.

Element identity vs component identity: the resets you did not order

The flip side of “same type keeps state” is that same component type at the same position keeps state even across completely different JSX branches. Render <Counter person="Taylor" /> in the true branch of a ternary and <Counter person="Sarah" /> in the false branch: toggling does not reset the count. React sees position 1, type Counter, both times — same fiber, props updated, state preserved. If those are logically different counters, that is a bug you fix by giving the branches different keys — key="taylor" and key="sarah" — turning the key into a deliberate reset lever. The same lever resets a form when the entity changes: <ProfileForm key={userId} /> remounts the whole form on user switch instead of leaking one user’s edits into another’s profile.

And the inverse failure: defining a component inside another component. The inner function is recreated on every parent render, so its identity differs every time; React compares by reference for function types, sees a “different type” at the same position, and unmounts the subtree — every parent render wipes the child’s state, refocuses nothing, replays mount effects. It looks like “my input loses focus on every keystroke” and is among the most-filed non-bugs in React’s tracker. Hoist the definition to module scope; if it needs the parent’s data, pass props.

Pick the best fit

A chat sidebar renders a list of conversations sorted by last activity. Each row owns an uncontrolled draft input. A new message arrives mid-keystroke and re-sorts the list. Which key strategy is correct?

Quiz

A sorted list of rows with uncontrolled inputs uses key={index}. The list re-sorts while a user is typing in the top row. Why does the typed text end up under a different data item?

Quiz

A developer defines ChildInput as a function inside the Parent component's body. Users report the input loses focus and clears on every keystroke. What is the mechanism?

Recall before you leave
  1. 01
    Explain the exact mechanism by which key=index corrupts state on reorder, including where the state physically lives.
  2. 02
    When does React unmount a subtree even though you never removed it from your JSX, and how do you use keys to force or prevent resets deliberately?
Recap

Reconciliation is React’s O(n) answer to an O(n³) problem, built on two heuristics: different element types produce different trees, and keys mark stable identity. Each render produces brand-new element objects — pure descriptions with no state — and React matches them against the persistent fiber tree, where hook state, uncontrolled input values, focus, and DOM nodes actually live. Same type and key at a position: the fiber survives, props update, children are reconciled in turn. A changed type: the entire subtree is unmounted — state destroyed, cleanups run, DOM removed — and rebuilt. For arrays of siblings the match is by key, which is why index-as-key is the canonical production bug: the key restates the position, so a re-sort matches the position-0 fiber (and the draft typed into it) to whatever data item now occupies position 0, and a delete shifts every pairing by one while unmounting the last row instead of the deleted one. Random keys fail the other way, remounting every row on every render. A stable data id makes state and DOM follow the logical item, costing a node reposition instead of a teardown. Identity cuts both ways: the same component type at the same position keeps state across JSX branches whether you wanted that or not, and a key change is the deliberate reset lever — key equals userId remounts a form on entity switch. The stealth type-change — defining a component inside another component, recreating the function reference each render — remounts its subtree on every parent render and presents as inputs losing focus per keystroke. Type plus key is identity; identity decides whether state survives. Now when you see state behaving as if it followed the wrong data, or an input losing focus on every keystroke, you have the vocabulary to read the reconciliation trace and know exactly which rule was broken.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.