React Reconciliation in Practice: Keys, Identity and the Real Cost of Re-renders
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Two different bugs with the same root. Delete the first row of a list and the ticked checkbox stays behind on the second row. Type into one input in a 2,000-row table and the whole page stutters on every character. Both come from the same thing: React doesn’t realize that what it had before and what it has now are the same thing.
1. Reconciliation: A Diff Problem React Refuses to Solve
React doesn’t mutate the DOM directly. Every time state changes, the component returns a new element tree, and React has to work out where the old and new trees differ.
Matching two arbitrary trees is the tree edit distance problem, with O(n³) cost: a 1,000-node page would take around a billion comparisons for every character typed. React gives up generality, because real UIs only change locally, and uses two heuristics.
One: compare by position and type. Same position, same type means the same thing: update props, keep the DOM node and state. Different type and React unmounts the whole subtree and mounts a new one. The DOM nodes are recreated, state resets, old effects clean up and new effects run.
Two: key is the escape hatch for lists. Inside an array of elements, position no longer says anything about identity, so you declare it yourself. Diffing a list becomes labeled matching, still O(n).
The variant of the different-type bug I see most often is a component defined inside another component:
// Every time Parent renders, `Row` points to a NEW function.
// To the reconciler that is a different type → unmount, then remount every <li>.
// Result: the input loses focus after every character, state inside Row resets.
function Parent({ items }: { items: Item[] }) {
const Row = ({ item }: { item: Item }) => <li>{item.label}</li>;
return (
<ul>
{items.map((item) => (
<Row key={item.id} item={item} />
))}
</ul>
);
}
// Correct: define it at module level so the type doesn't change between renders.
function Row({ item }: { item: Item }) {
return <li>{item.label}</li>;
}
function Parent({ items }: { items: Item[] }) {
return (
<ul>
{items.map((item) => (
<Row key={item.id} item={item} />
))}
</ul>
);
}
Reconciliation is a heuristic. Same position but different type, and React throws away all the state of the old subtree.
2. key Is Identity, Not Something to Make the Warning Go Away
Most people first meet key through a warning, and the reflex is to add key={index} to quiet it. That reflex is wrong, because an array index is not identity. It’s position, and position changes when the list changes.
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo, index) => (
<li key={index}>
<input type="checkbox" defaultChecked={todo.done} />
<span>{todo.title}</span>
</li>
))}
</ul>
);
}
Take [A, B, C] and delete A, leaving [B, C]. React compares by position, sees position 0 with the same type li and the same key 0, treats it as the same row and just updates the text. The DOM node isn’t removed.
That’s where the bug comes from. The checkbox state doesn’t live in React, it lives in the DOM node. For a checkbox the user has interacted with, checked no longer follows the attribute. The old node is reused, but the tick stays on row B. Component state behaves the same way: the row that was open for editing suddenly becomes a different row. Fix it with business identity:
{todos.map((todo) => (
<li key={todo.id}>
<input type="checkbox" defaultChecked={todo.done} />
<span>{todo.title}</span>
</li>
))}
Three criteria for a good key: stable across renders, unique among siblings, generated from data. JSON.stringify(todo) breaks the last criterion. Edit the title and the key changes, the row remounts, and whatever you were typing in the input is reset.
key={Math.random()} in render makes every key change on every render. The whole list gets torn down and rebuilt, state and focus are lost, the DOM churns. Worst of all, it still “works”, so the bug stays around for a very long time.
key is a signal for the reconciler, not a prop the component can read. If you need the id inside the row, pass it as a separate prop. And <></> doesn’t accept a key, so you have to use <Fragment key={...}>.
3. Fiber: The Render Phase and the Commit Phase Aren’t the Same
The rule “don’t cause side effects in the component body” has a reason: React splits the work into two very different phases.
flowchart TB
S["setState / new props"] --> R["Render phase<br/>reconcile by position + type + key"]
R --> K{"key matches?"}
K -- "matches" --> U["Reuse fiber<br/>keep state"]
K -- "different / index key shifted" --> M["Unmount + remount<br/>state and DOM reset"]
U --> C["Commit phase<br/>mutate DOM, run effects"]
M --> C
The unit of work is the fiber: a node in a linked tree (child / sibling / return) carrying type, props, state and flags marking the work to be done. That structure lets React stop partway, yield the main thread and come back later.
The render phase runs the component functions, compares the result with the old tree and records the changes. This phase can be interrupted, re-run or thrown away entirely. A low-priority update that’s mid-render can get cut in by a higher-priority one. In dev, StrictMode calls component functions twice to expose impure components.
The commit phase is synchronous and can’t be interrupted: it mutates the DOM, runs useLayoutEffect before the browser paints, yields to the browser to paint, then runs useEffect after paint.
The consequence: the component body has to be pure. No fetching, no logging, no random id generation, no mutating props or state. One render may run twice or get discarded. The side effect has already happened.
function Chart({ series }: { series: Series }) {
// Wrong: render can run twice (StrictMode) or be discarded in a
// concurrent render → junk requests, analytics counts are off.
void fetch("/api/track", { method: "POST", body: String(series.id) });
// Wrong again: an id generated in render isn't stable between renders.
const domId = `chart-${Math.random().toString(36).slice(2)}`;
// Correct: render only computes from props and state.
const peak = Math.max(...series.points.map((p) => p.value));
return <svg id={domId}>{/* ... */}</svg>;
}
4. Measure First, and Measure the Right Thing
Re-render highlighting in React DevTools tells you which regions rendered after an interaction. It’s a tool for locating, not for drawing conclusions: a component that renders 200 times but whose body just returns three divs isn’t worth your attention.
The Profiler in DevTools gives self time (in the component itself) and total time (including children). Small self time with large total time means the component is dragging the whole tree along. Turn on “Record why each component rendered while profiling”: if it rendered because of its own state, memo can’t save you; if it rendered because of its parent, it might.
The <Profiler> API gives you numbers automatically, and works in production too:
import { Profiler, type ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (
id, // the Profiler's id
phase, // "mount" | "update" | "nested-update"
actualDuration, // time React actually spent on this commit
baseDuration, // estimated render time if there were NO memoization
) => {
metrics.observe("react.render.ms", actualDuration, { id, phase });
};
export function Table({ rows }: { rows: Row[] }) {
return (
<Profiler id="Table" onRender={onRender}>
<RowList rows={rows} />
</Profiler>
);
}
baseDuration is the estimated render time without memoization. If actualDuration is clearly lower, memo is doing its job; if the two numbers are close, the memoization work was pointless.
A limit: Profiler only measures React’s render. It can’t see layout or paint. A component that renders in 0.5ms but inserts 5,000 DOM nodes will be invisible on the flame chart.
5. Why Context Drags Along Every Consumer
Context is publish/subscribe, with no selector. When the provider’s value changes (compared with Object.is on the value itself), React marks every consumer as needing an update. It doesn’t check whether a consumer actually uses the part of the data that just changed.
First trap: a value like {{ user, login }} written directly in JSX creates a new reference on every provider render, even when the data hasn’t changed.
function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<Item[]>([]);
// Stable reference: the callback isn't recreated on every render.
const add = useCallback((item: Item) => {
setItems((prev) => [...prev, item]);
}, []);
// The value only changes when the data inside changes.
const value = useMemo(() => ({ items, add }), [items, add]);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
Second trap: React.memo can’t block a re-render caused by context. Memo compares props, and a context subscription is a separate path. The consumer still renders even when its props are identical.
The cure is to make the granularity of what’s subscribed match what changes: split contexts by update frequency (theme almost never changes, the cart changes on every click), move the provider lower, or use a store with selectors.
A third route uses useSyncExternalStore: each subscriber declares a function that picks its data, React takes a snapshot and compares with Object.is, and only re-renders when the picked result changes.
import { create } from "zustand";
type CartState = { items: CartItem[]; add: (item: CartItem) => void };
export const useCart = create<CartState>()((set) => ({
items: [],
add: (item) => set((state) => ({ items: [...state.items, item] })),
}));
// The selector returns a number → Object.is compares correctly, and the
// component only re-renders when this row's own number changes.
export function RowQty({ id }: { id: string }) {
const qty = useCart((s) => s.items.find((i) => i.id === id)?.qty ?? 0);
return <span>{qty}</span>;
}
The cost: selectors run for every subscriber on every store change. A find over a 5,000-element array times 1,000 subscribers is a new trap replacing the old one. Normalize into a map keyed by id.
6. memo, useMemo, useCallback and the Root: Referential Identity
These three APIs solve exactly one problem: JS compares objects, arrays and functions by reference, not by content, and React does too.
React.memo shallow-compares props with Object.is, so an object literal or an arrow function created fresh on every render means different props, and memo can’t skip. useCallback(fn, deps) is essentially useMemo(() => fn, deps), so first look for the prop whose reference keeps changing.
A technique stronger than memo, and easy to overlook, is children-as-prop. When a component re-renders because of its own state, its props stay the same. An element created in the parent and passed through props.children has an unchanged reference, so React skips the whole subtree.
// Before: App holds the input's state, so every keystroke re-renders the
// whole ExpensiveTree, even though it has nothing to do with query.
function App() {
const [query, setQuery] = useState("");
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ExpensiveTree />
</>
);
}
// After: state moves down into SearchBox. The <ExpensiveTree /> element is
// created in App and passed down; when SearchBox re-renders, props.children
// is still the same reference → React skips that subtree.
function App() {
return (
<SearchBox>
<ExpensiveTree />
</SearchBox>
);
}
function SearchBox({ children }: { children: React.ReactNode }) {
const [query, setQuery] = useState("");
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{children}
</>
);
}
“You may rely on useMemo as a performance optimization, not as a semantic guarantee.”
That line in the useMemo docs means React is allowed to throw away a cached value. In practice that happens on unmount, and in principle at any time. So don’t let your program’s correctness depend on it: don’t use useMemo to create a connection once, to cache getSnapshot, or to hold an id that carries the meaning of a key.
useMemo also doesn’t make the first render faster. On mount there’s nothing to compare against, so the factory always runs, and you only add the cost of storing the cache. Its value is in later updates.
The price of memoizing: the comparison runs on every render, closures are held longer than needed, and the dependency array is a hand-written contract that no compiler checks for you. It backfires when props change on every render anyway, when the component is cheap to begin with, or when the team has already turned on React Compiler.
7. Derived State and Where State Lives
Two state design mistakes cause most unnecessary re-renders, and neither has anything to do with memo.
Mistake one: syncing a derived value with an effect.
// Wrong: `total` can be computed entirely from props, but it's kept
// in state and synced with an effect.
function PriceTable({ items, taxRate }: { items: Item[]; taxRate: number }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((n, i) => n + i.price, 0) * (1 + taxRate));
}, [items, taxRate]);
return <strong>{total}</strong>;
}
This renders twice on every change: first with the old total, commit, the effect runs, setState, render again. In between there’s a wrong intermediate state. The right way is to compute it right in render:
function PriceTable({ items, taxRate }: { items: Item[]; taxRate: number }) {
// No extra state, no effect, no wrong intermediate frame.
const total = items.reduce((n, i) => n + i.price, 0) * (1 + taxRate);
return <strong>{total}</strong>;
}
If the computation really is expensive (measure first), useMemo is the right tool, and then it’s only an optimization, not where the truth is kept.
Mistake two: state sitting higher than it needs to. State should live in the lowest common ancestor of the components that actually read it. A filter box inside a modal whose state lives in the page component means the whole table re-renders on every keystroke. The exception is state that has to be higher up, like table selection because the header needs it.
And key comes back in its proper role when you want to reset state based on data: <ProfileForm key={userId} userId={userId} /> states that intent directly, more clearly than a useEffect watching userId and resetting each field.
8. In Practice: A 2,000-Row Table, One Input
The problem: a 2,000-row table, each row with a selection checkbox and a quantity input, selection that survives filtering, and typing in one cell should cause nothing but its own re-render.
The very common wrong approach: all the state sits in the table component, including every row’s in-progress draft in a Record<string, string>. Each keystroke creates a new object, so the whole table renders again, and memoizing Row doesn’t save you.
The right approach: classify state by who reads it. Cross-row selection belongs to the table component, a half-typed draft belongs to the row itself, and saved data belongs to a store normalized by id. And for memo to work, the props passed down have to be primitives or stable references.
type Row = { id: string; name: string; qty: number };
const RowItem = memo(function RowItem({
id,
selected,
onToggle,
}: {
id: string;
selected: boolean; // primitive → shallow compare works
onToggle: (id: string) => void;
}) {
const row = useStore((s) => s.byId[id]); // the row reads its own data
const [draft, setDraft] = useState(row.name); // draft state lives in the row
return (
<li>
<input type="checkbox" checked={selected} onChange={() => onToggle(id)} />
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<span>{row.qty}</span>
</li>
);
});
function List({ ids }: { ids: string[] }) {
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
// Callback is stable for List's whole lifetime → doesn't break RowItem's memo.
const toggle = useCallback((id: string) => {
setSelected((prev) => {
const next = new Set(prev); // create a new Set, don't mutate the old state
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
return (
<ul>
{ids.map((id) => (
<RowItem
key={id} // identity from data, not from index
id={id}
selected={selected.has(id)} // a boolean, not the Set
onToggle={toggle}
/>
))}
</ul>
);
}
Three decisions carry the performance: selected.has(id) is evaluated right where the prop is passed so it becomes a boolean (pass the whole Set down and every row sees a new prop after each tick); toggle has an empty dependency array so its reference never changes; row data is read through a selector inside the row.
The cost has to be stated honestly: on each tick, List still re-renders, creating 2,000 elements and running 2,000 props comparisons. That’s acceptable at 2,000 rows, but it’s O(n) and shows up at 20,000 rows, at which point selection has to move into the store.
Past some threshold, all of the above is optimizing the wrong thing. With 20,000 DOM elements the browser has to style and lay out all of them, and no memo touches that. You need virtualization: render only the ~30 rows in view plus a spacer to hold the height. The costs: you have to measure variable-height rows, find-in-page stops working, and using the index as a key in a virtual list means keys change as you scroll. That’s the bug from section 2 coming straight back.
useDeferredValue solves exactly the “keep the input smooth while the list is heavy” problem: React renders the list at lower priority, lets the input update first, or discards the half-finished render if the user keeps typing.
9. A Checklist for When the UI Is Slow or State Jumps
Don’t start with useMemo.
- Identify the symptom: too many DOM nodes, layout constantly redone, choppy animation: not a re-render problem. State in the wrong place or lost after a list operation: an identity bug.
- Check
key:key={index}, keys generated in render, components defined inside components. - Profile one specific interaction: which component has a large total time, and did it render because of its own state, its parent or context.
- Check where state lives, any
useState+useEffectpairs that could be computed in render, and the identity of props/values (object/array/function literals in JSX, unmemoized context values, selectors returning new objects). - Only now reach for
memo/useMemo/useCallback, and only on components that have proven expensive. If there are still too many DOM nodes, window the list.
A re-render isn’t expensive because there are many of them. It’s expensive because React fails to recognize “this is still the same thing”. Every time the answer is no, React starts over. Worst case, it resets exactly the state the user was in the middle of typing.
Keep reading
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.