Stable Keys & Identity
React decides whether to update an existing component or throw it away and build a new one by comparing identity between renders. For list items, identity comes from the key prop; for everything else, it comes from the component’s position and type in the tree. When that identity is unstable—index keys that shift, keys regenerated on every render, or components defined inside another component—React unmounts and remounts instead of updating. The result is lost local state, discarded DOM, re-run effects, and a lot of wasted work that profiling rarely explains at first glance.
Why keys decide identity
When React reconciles a list, it pairs old elements with new ones by matching keys. A matched key means “same component, update it in place.” A new or missing key means “different component, mount it from scratch.” Keys are how you tell React which item is which when the order or contents of a list change.
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}
Here todo.id is a stable identity tied to the data. Reorder, insert, or delete todos and React still recognizes each <li> and moves it rather than rebuilding it.
Index keys break on reorder
Using the array index as a key looks fine until the list changes order or items are inserted or removed at the front or middle. The index describes a position, not an item, so when positions shift, React matches the wrong elements together and updates them with the wrong data—while any internal state stays glued to the old position.
import { useState } from "react";
function EditableRow({ label }) {
const [draft, setDraft] = useState("");
return (
<li>
{label}: <input value={draft} onChange={(e) => setDraft(e.target.value)} />
</li>
);
}
function List({ items }) {
return (
<ul>
{items.map((item, index) => (
// BAD: index key
<EditableRow key={index} label={item.name} />
))}
</ul>
);
}
Type “hello” into the second row’s input, then prepend a new item to items. Because the keys are 0, 1, 2…, React keeps the same components for each index and only changes the label prop.
Output:
Before prepend: [Apple: ""] [Banana: "hello"]
After prepend: [Cherry: ""] [Apple: "hello"] // draft jumped to the wrong row
The draft state stayed with index 1, which now points at a different item. Switching the key to item.id fixes it: React moves the existing component to its new position and the input’s state travels with the correct row.
Index keys are only safe when the list is static—never reordered, filtered, or edited in the middle. The moment order can change, use a stable id.
Regenerated keys remount everything
A key that is recomputed every render—Math.random(), Date.now(), crypto.randomUUID(), or index + someChangingValue—never matches the previous render. React sees a brand-new key, so it unmounts the old component and mounts a new one on every render.
// BAD: a fresh key on every render
{todos.map((todo) => (
<TodoItem key={Math.random()} todo={todo} />
))}
Every parent render destroys and rebuilds the entire list: state resets, inputs lose focus, animations restart, and effects clean up and re-run. Generate ids once when the data is created, not during render.
// GOOD: id assigned when the item is created
function addTodo(text) {
setTodos((prev) => [...prev, { id: crypto.randomUUID(), text }]);
}
Components defined inline are a new type every render
Identity also applies to component type. Defining a component inside the body of another component creates a new function reference on every render. React compares the old type to the new type, sees they differ, and remounts the whole subtree—even if the JSX you return looks identical.
function Page() {
// BAD: Panel is redefined on every Page render
function Panel({ children }) {
const [open, setOpen] = useState(true);
return open ? <section>{children}</section> : null;
}
return <Panel>Dashboard</Panel>;
}
Each time Page renders, Panel is a different function, so React unmounts the previous Panel and mounts a fresh one. Its open state resets and its effects re-run on every parent render. The fix is to lift the component to module scope so its identity is stable.
// GOOD: defined once, stable identity
function Panel({ children }) {
const [open, setOpen] = useState(true);
return open ? <section>{children}</section> : null;
}
function Page() {
return <Panel>Dashboard</Panel>;
}
The same trap applies to higher-order components and memo() calls: wrapping inside render (const Memoized = memo(Inner) in the body) produces a new memoized component each time, defeating the memoization entirely. Always wrap at module scope.
When you want a remount
Forcing a remount is occasionally the goal—resetting an uncontrolled form or a stateful widget when a record changes. Changing the key on purpose is the idiomatic way to do this.
// Reset internal state whenever the selected user changes
<ProfileForm key={userId} user={user} />
When userId changes, React mounts a fresh ProfileForm, clearing any draft state. This is intentional identity control, not an accident.
Comparison of key strategies
| Strategy | Identity stable? | Use when |
|---|---|---|
key={item.id} | Yes | Default for any dynamic list |
key={index} | Only if list never reorders | Static, append-only lists |
key={Math.random()} | Never | Never |
key={changingValue} on purpose | Intentionally changes | Forcing a deliberate reset |
| Component defined in render body | Never | Never—lift to module scope |
Best Practices
- Key list items by a stable, data-derived id; assign ids when data is created, not during render.
- Reserve index keys for lists that are never reordered, filtered, or edited in the middle.
- Never use
Math.random(),Date.now(), or a fresh UUID as a key during render. - Define every component, HOC, and
memo()wrapper at module scope so its type identity is stable. - Use a deliberately changing
keyonly when you actually want to reset a subtree’s state. - Watch the React DevTools Profiler for unexpected “mount” entries—they reveal identity churn that simple re-render counting misses.