open atlas
↑ Back to track
React patterns, senior RXP · 14 · 03

Context misuse & key-as-index

Two anti-patterns that pass a demo and fail in production: high-frequency or local state in context (tree-wide re-render storms) and key-as-index (list state corrupting on reorder/insert) — plus their fixes and the over-correction traps.

RXP Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

Both of these bugs review clean. The code reads fine, the demo works, the PR merges. Then the app gets real data and real interaction, and one of them turns a smooth UI into a janky one while the other silently scrambles which input belongs to which row.

They’re a matched pair because both are about identity done wrong. Context misuse gives state the wrong scope — too wide, so a value that changes constantly re-renders a whole subtree. Key-as-index gives list items the wrong identity — positional instead of stable, so React reuses the wrong component when the list reorders. Neither shows up at 3 items in a demo; both show up at 300 items with users typing.

Goal

After this lesson you can recognize the two anti-patterns on sight, explain the exact failure each one produces (tree-wide re-render storms from over-scoped/high-frequency context; corrupted input/focus/selection state from index keys on a mutable list), apply the real fixes (split or relocate context so high-frequency state isn’t shared; key by a stable id), and — just as important — avoid the over-corrections: prop-drilling everything to dodge context, or using random keys that remount every row on every render.

1

Anti-pattern A: context whose value changes often re-renders every consumer, no matter how deep. A context Provider re-renders all consumers whenever its value changes identity. Put high-frequency state — a controlled input, a mouse position, a timer — in a wide context and every consumer in the subtree re-renders on every keystroke or frame. The “convenience” of not threading a prop becomes a tree-wide render storm.

// Anti-pattern: form draft (changes on every keystroke) shared app-wide
const AppContext = createContext<{ draft: string; setDraft: (s: string) => void } | null>(null);

function App() {
  const [draft, setDraft] = useState("");
  // every keystroke → new value object → EVERY consumer re-renders
  return (
    <AppContext.Provider value={{ draft, setDraft }}>
      <Header />   {/* re-renders on each keystroke */}
      <Sidebar />  {/* re-renders on each keystroke */}
      <Editor />   {/* the only thing that actually needs draft */}
    </AppContext.Provider>
  );
}

It passes a demo because three cheap children re-rendering is invisible. In production, those children are expensive trees, and the typing lags.

2

The fix is scope, not avoidance: keep high-frequency or purely-local state out of wide context — colocate it, or split context by change-frequency. If only Editor needs the draft, that state belongs in Editor (or its nearest shared parent), not the app root. When state genuinely is shared but mixes stable and volatile data, split it: a rarely-changing context (theme, user) stays wide; the volatile slice gets its own narrow provider, or moves out entirely. Also remember a fresh value={{ ... }} object each render is itself a re-render trigger — memoize it when the context legitimately stays wide.

// Fix: the draft lives where it's used; context carries only stable, shared data
const ThemeContext = createContext<Theme>("light"); // changes rarely → safe to be wide

function App() {
  const theme = useTheme();
  return (
    <ThemeContext.Provider value={theme}>
      <Header />
      <Sidebar />
      <Editor /> {/* owns its own draft useState — no tree-wide churn */}
    </ThemeContext.Provider>
  );
}

Scope each piece of state to exactly who needs it. Context is for shared state, and works best when that shared state changes infrequently.

3

Anti-pattern B: key={index} ties a component’s identity to its position, so any reorder/insert/delete makes React reuse the wrong instance. Keys tell React which element is which across renders. With index keys, “the item at position 0” is always key 0 — so if you prepend, insert, or sort, React thinks the new item at position 0 is the same element as before and keeps the old component’s internal state (input text, focus, selection, scroll, a checkbox) attached to the wrong data.

// Anti-pattern: index keys on a list the user can reorder / add to
{todos.map((todo, i) => (
  <li key={i}>
    {todo.label}
    <input defaultValue={todo.note} /> {/* uncontrolled state lives in the DOM node */}
  </li>
))}
// Prepend a todo → every note input now shows the PREVIOUS row's text.

Static, never-reordered lists survive index keys, which is exactly why this slips through review — the bug is dormant until the list mutates.

4

The fix is stable identity: key by a value that travels with the item — a server id or a generated id created when the item is born, never the array index, and never Math.random(). A stable key means React keeps each component glued to its own data through any reorder, so input text, focus, and selection follow the right row. The two over-corrections both fail: key={Math.random()} produces a new key every render, so React remounts every row on every render (state lost, focus lost, worst case). And key={JSON.stringify(item)} re-keys whenever any field changes, remounting on edit.

// Fix: a stable id that belongs to the item
{todos.map((todo) => (
  <li key={todo.id}>{todo.label}<input defaultValue={todo.note} /></li>
))}
// If the source has no id, mint one at creation time, not at render time:
const addTodo = (label: string) =>
  setTodos((t) => [...t, { id: crypto.randomUUID(), label, note: "" }]);

Index keys are acceptable only when the list is static, never reordered/filtered, and items have no identity and no internal state — a frozen menu, not a user-editable list.

Worked example

One editor screen, both bugs, both fixes. A reorderable list of fields, each with its own text input, plus a shared “dirty” flag — naively built, it ships both anti-patterns.

// BEFORE — context misuse + index keys
const EditorCtx = createContext<any>(null);

function Editor({ rows, setRows }) {
  const [draft, setDraft] = useState("");           // high-frequency
  // ⚠️ new object every keystroke → whole subtree re-renders
  return (
    <EditorCtx.Provider value={{ draft, setDraft, rows, setRows }}>
      <Toolbar />            {/* re-renders on every keystroke */}
      {rows.map((row, i) => (
        <Field key={i} row={row} />   {/* ⚠️ index key */}
      ))}
    </EditorCtx.Provider>
  );
}
// Prepend a row → Field at index 0 keeps the old input's text/focus.

Now both fixes, each targeting its own failure:

// AFTER — scoped state + stable keys
function Editor({ rows, setRows }) {
  return (
    <>
      <Toolbar />                              {/* no longer re-renders on draft change */}
      {rows.map((row) => (
        <Field key={row.id} row={row} />       {/* stable identity follows the data */}
      ))}
    </>
  );
}

function Field({ row }: { row: Row }) {
  const [draft, setDraft] = useState(row.note); // draft colocated → only this Field re-renders
  return <input value={draft} onChange={(e) => setDraft(e.target.value)} />;
}

The draft now lives in the one component that uses it (no provider, no storm), and each Field is keyed by row.id, so a reorder reattaches each input to its own data. Notice the discipline: we didn’t delete context out of fear (Toolbar/rows could still share a context if they truly needed to), and we didn’t reach for random keys — we matched scope to who-needs-it and identity to the item.

Why this works

Why does over-scoped context cost so much when “it’s just a re-render”? Because context bypasses the usual escape hatch. Normally an unchanged prop lets a memo’d child skip re-rendering. But a consumer of a changed context re-renders unconditionallymemo does not stop it. So a high-frequency value in a wide provider re-renders every consumer in the subtree on every change, with no per-component opt-out short of restructuring. That’s why the fix is structural (scope/split the context), not a sprinkle of memo.

Common mistake

The over-correction is the real trap, because it looks like “the safe choice”. Scared of context re-renders, a team prop-drills a value through eight layers — now every intermediate component re-renders anyway and couples to data it doesn’t use. Scared of index keys, someone writes key={Math.random()} — now every row remounts on every render, losing focus and input state on every keystroke, a strictly worse bug than the one they fixed. The correct moves are precise: colocate or split context (don’t abolish it), and key by a stable id (not a random or content-derived one).

Check yourself
Quiz

A reorderable list of rows, each with an uncontrolled <input>, uses key={index}. After a drag-to-reorder, the inputs show the wrong rows' text. A teammate proposes key={Math.random()}. What's the correct fix, and what's wrong with the proposal?

Recap

Two production bugs that pass a demo, both about identity. Context misuse: a context re-renders every consumer when its value changes, and memo can’t stop it — so high-frequency or purely-local state in a wide provider becomes a tree-wide render storm. Fix by scope: colocate state where it’s used, split context by change-frequency, and memoize the value object when the context legitimately stays wide. Key-as-index: key={i} binds a component to a position, so any reorder/insert/delete reattaches old internal state (input text, focus, selection) to new data. Fix with a stable id that travels with the item — minted at creation, never the index, never Math.random() or a content hash. Both have a seductive over-correction that’s worse than the original: prop-drilling everything instead of scoping context, and random keys that remount every row every render. Senior judgment here is precise targeting — right scope for state, right identity for list items — not blanket avoidance.

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 4 done

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.