React's rendering model: reconciliation, keys, hooks
What it is
React's rendering model is a diff over a tree of component instances, driven by two heuristics and one piece of developer-supplied identity information. Understanding it means being able to answer three questions precisely: when does a component re-render, what state survives a re-render, and why do hooks have rules.
render phase call your components, build a new tree, diff it
against the current one. Interruptible in React 18+.
MUST BE PURE: no DOM writes, no subscriptions.
commit phase apply the computed mutations to the DOM, run layout
effects synchronously, then passive effects after paint.
Synchronous and uninterruptible.
What this is confused with: "the virtual DOM is fast." It is not fast, it is predictable. A DOM mutation costs the same whether React or you make it, and React does strictly more work than a hand-written mutation because it also builds and diffs a tree. What it buys is that you write declarative code and React derives a correct minimal-ish mutation set.
Also confused: re-render means DOM update. A re-render calls your function and diffs. If the output is equivalent, zero DOM mutations happen. Most "unnecessary re-render" panic is about function calls that produce no DOM work at all, and most real performance problems are about expensive render functions or genuinely large mutation sets.
The problem it solves
Manual DOM manipulation does not compose. With imperative updates, every piece of code that can change state must know every piece of DOM that depends on it:
state changes: user.name
-> update the header avatar tooltip
-> update the sidebar greeting
-> update the 14 comment author labels currently rendered
-> update the open edit dialog, if it is open
Each new dependency is a new edge. n pieces of state and m pieces of UI
gives you up to n*m edges to maintain, and every bug is "someone forgot
an edge."
React replaces that with one edge per component: state in, description of UI out. The cost is that React must figure out the mutations, which is what reconciliation does, and it must decide which component instance in the new tree corresponds to which instance in the old one, which is what keys do.
Mechanics
The two reconciliation heuristics
A general tree-diff is O(n³). React uses two assumptions to get O(n):
1. Different element types produce different trees. If the type at a position changes, React unmounts the entire old subtree (destroying its state and running cleanup) and mounts the new one.
// A change from <div> to <span> at this position destroys everything
// below it, including any component state in the subtree.
{isEditing ? <div><Editor /></div> : <span><Editor /></span>}
// Editor's state is LOST on toggle: the parent type changed.
{isEditing ? <div><Editor mode="edit" /></div>
: <div><Editor mode="view" /></div>}
// Editor's state SURVIVES: same type at the same position.
Position in the tree plus type is the identity, which is why this is surprising:
// These look equivalent. They are not.
{cond ? <Counter label="a" /> : <Counter label="b" />} // state SURVIVES the toggle
{cond && <Counter label="a" />}
{!cond && <Counter label="b" />} // state is DESTROYED
In the second form the two Counters are at different positions in the children array, so
toggling unmounts one and mounts the other.
2. Keys let the developer supply identity across renders. Within a list, React matches children by key. Without a key, it matches by index.
Why index-as-key breaks lists
// The bug, in its most reproducible form.
function TodoList({ todos, onRemove }) {
return todos.map((todo, i) =>
<li key={i}> {/* WRONG */}
<input defaultValue={todo.text} /> {/* uncontrolled: DOM owns the value */}
<button onClick={() => onRemove(todo.id)}>x</button>
</li>
)
}
Start with three todos, type into each input, then delete the first:
Before delete React's view (key = index)
key 0: "buy milk" 0 -> "buy milk"
key 1: "call bank" 1 -> "call bank"
key 2: "ship PR" 2 -> "ship PR"
After delete of item 0, the array is [call bank, ship PR]
key 0: "call bank" 0 exists in both -> UPDATE IN PLACE
key 1: "ship PR" 1 exists in both -> UPDATE IN PLACE
2 is gone -> UNMOUNT
React reuses DOM node 0 (which holds the text you typed for "buy milk")
and reuses node 1 (which holds "call bank"), then destroys node 2.
Result on screen: the first row shows "call bank" as its label but the
input still contains what you typed for "buy milk". The DOM state slid
up by one and the last row's DOM state was thrown away.
The damage is to state React does not own: uncontrolled input values, focus, scroll position,
text selection, in-flight CSS transitions and animations, <video> playback position, and any
component state inside the row.
<li key={todo.id}> {/* right: identity is the thing, not the slot */}
With stable keys React matches id=2 to id=2, sees it is unchanged, and issues a single node
removal.
When index keys are safe: the list is never reordered, never filtered, never has items inserted or removed anywhere but the end, and the rows hold no DOM or component state. That is a real condition (a static rendered table), and it is narrow enough that "use the id" is the correct default.
Keys are also a deliberate reset tool. Changing a key on purpose destroys and recreates state:
// Reset the whole form when the user being edited changes.
<ProfileForm key={userId} user={user} />
That is the documented React idiom for "reset state on prop change," and it is far more reliable
than a useEffect that syncs props into state.
Why re-renders cascade, and how React bails out
Rendering a component renders its children by default, because calling Parent() produces new
child element objects, which are not referentially equal to the previous ones.
function Parent() {
const [n, setN] = useState(0)
return <><button onClick={() => setN(n+1)}>{n}</button><ExpensiveChild /></>
}
// ExpensiveChild re-renders on every click. Its props did not change,
// but the ELEMENT is new each time, so React does not bail out.
Three ways out, in order of preference:
// 1. Move state down. The cheapest fix and the one people skip.
function Parent() { return <><Counter /><ExpensiveChild /></> }
// 2. Pass children through. The element is created in a component that
// does NOT re-render, so it is referentially stable and React bails out.
function Parent({ children }) {
const [n, setN] = useState(0)
return <><button onClick={() => setN(n+1)}>{n}</button>{children}</>
}
<Parent><ExpensiveChild /></Parent>
// 3. React.memo. Shallow-compares props. Useful, and it fails silently
// the moment you pass an inline object, array or arrow function.
const ExpensiveChild = React.memo(function ExpensiveChild({ config }) { ... })
<ExpensiveChild config={{ mode: 'x' }} /> // new object every render: memo is a no-op
React.memo with an inline object prop is the most common wasted optimisation in React
codebases, because it adds a comparison cost and never returns true.
Hooks: why the rules exist
Hook state is not stored by name. It is a linked list on the fiber, indexed by call order.
Fiber for <Profile />
memoizedState -> hook0 {state: "ada"} <- useState("ada")
-> hook1 {state: 0} <- useState(0)
-> hook2 {deps: [id]} <- useEffect(fn, [id])
On every render React walks that list in order, returning slot 0 to the first hook call, slot 1 to the second, and so on. There is no key, no name, nothing but position.
function Profile({ id, showBio }) {
const [name, setName] = useState("ada") // slot 0
if (showBio) {
const [bio, setBio] = useState("") // slot 1, CONDITIONALLY
}
const [age, setAge] = useState(0) // slot 1 or slot 2 (!!)
...
}
Render 1 (showBio true): name<-0, bio<-1, age<-2
Render 2 (showBio false): name<-0, age<-1 // age now reads BIO's slot
age silently becomes "". That is the entire reason for "only call hooks at the top level," and
it is why the rule is mechanical rather than stylistic. The same reasoning covers loops, early
returns before a hook, and calling hooks from plain functions (which have no fiber to store slots
on).
eslint-plugin-react-hooks enforces both rules statically, and it is not optional in a codebase of
any size.
The useEffect dependency traps
Dependencies are compared element-wise with Object.is, which is reference equality for
objects, arrays and functions.
Trap 1: a fresh object or function in the deps array.
function Search({ filters }) { // filters = {q: 'x'} created by the parent
useEffect(() => { fetchResults(filters) },
[filters]) // new object every parent render -> runs EVERY render
}
Fixes, in order: depend on primitives ([filters.q, filters.page]), or memoize the object at its
source with useMemo, or move the whole thing to a query library that keys on a serialised value.
Wrapping the consumer in useCallback does not help if the producer keeps making new objects;
the fix belongs where the value is created.
Trap 2: the omitted dependency and the stale closure.
useEffect(() => {
const t = setInterval(() => setCount(count + 1), 1000) // captures count from THIS render
return () => clearInterval(t)
}, []) // never re-runs
// count is captured as 0 forever. The counter goes 0, 1, 1, 1, 1...
The fix is the updater form, which needs no dependency at all:
useEffect(() => {
const t = setInterval(() => setCount(c => c + 1), 1000)
return () => clearInterval(t)
}, [])
Suppressing exhaustive-deps is almost always a bug you have not hit yet. When the lint rule
and your intent disagree, the honest response is to change the code so they agree: use the updater
form, move the value into a ref deliberately, or extract an event-handler-like function.
Trap 3: the async race. Effects are not cancelled when they re-run, only cleaned up.
useEffect(() => {
let cancelled = false
fetchUser(id).then(u => { if (!cancelled) setUser(u) })
return () => { cancelled = true }
}, [id])
Without the flag, typing quickly through ids 1, 2, 3 can leave you showing user 1, because response
order is not request order. An AbortController is better still, since it also stops the
request.
Trap 4: an effect used for derived state.
// WRONG: two renders, a stale intermediate frame, and a sync bug waiting.
const [full, setFull] = useState('')
useEffect(() => { setFull(first + ' ' + last) }, [first, last])
// RIGHT: it is not state, it is a computation.
const full = first + ' ' + last
Most useEffect calls that only touch React state should not exist. Effects are for
synchronising with something outside React: a subscription, a browser API, a network request, a
non-React widget.
Trap 5: forgetting that StrictMode in development mounts, unmounts and remounts. React 18's StrictMode intentionally runs setup, cleanup, setup on mount in development to surface missing cleanup. An effect that appears to "run twice" is telling you it has no cleanup, not that React is broken.
A worked example: a virtualised results table that lost typed text
A search results page rendering a filterable table. Each row has an expandable detail panel (component state) and an inline "notes" textarea (uncontrolled DOM state). Users reported two things: text they had typed into notes appeared on the wrong row after filtering, and expanding a row sometimes expanded a different one.
The relevant code:
function ResultsTable({ rows, query }) {
const visible = rows.filter(r => r.title.includes(query))
return (
<tbody>
{visible.map((r, i) => <ResultRow key={i} row={r} />)}
</tbody>
)
}
function ResultRow({ row }) {
const [open, setOpen] = useState(false) // component state
return (
<tr>
<td onClick={() => setOpen(!open)}>{row.title}</td>
<td><textarea defaultValue={row.notes} /></td> {/* DOM state */}
{open && <td>{row.detail}</td>}
</tr>
)
}
The reproduction, with numbers:
rows = 40 results
query = "" -> visible = 40, keys 0..39
User expands row at index 12 and types into its textarea.
User types "s" into the search box.
query = "s" -> visible = 11 results, keys 0..10
React's diff:
keys 0..10 exist in both trees -> UPDATE IN PLACE (11 rows reused)
keys 11..39 are gone -> UNMOUNT (29 rows destroyed)
Fiber at key 12 was destroyed, so `open` for that row is gone.
Fiber at key 4 (say) survives, still carrying open=false, and its
textarea DOM node still holds whatever was typed there earlier.
The row at position 4 now shows a DIFFERENT result's title with the
previous occupant's notes text underneath it.
Both reported symptoms fall out of one line. The expand bug is component state matched by slot; the notes bug is DOM state matched by slot. Index keys make "position in the filtered array" the identity, and filtering changes exactly that.
The fix:
{visible.map(r => <ResultRow key={r.id} row={r} />)}
And the second-order effect nobody expected. Before the fix, a keystroke in the search box produced this much work:
Typing "s" with index keys:
11 rows updated in place, EVERY cell mutated because the row's
content changed entirely (title, notes default, detail)
29 rows unmounted
-> roughly 11 * 4 attribute/text mutations + 29 node removals
Typing "s" with id keys:
11 rows matched to the same ids they already had -> props unchanged
-> React re-renders the components and issues ZERO DOM mutations
for the survivors
29 rows unmounted
Correctness was the reason to fix it; the mutation count fell out for free, because a stable key lets React discover that a surviving row did not change. That is the general shape: keys are a correctness feature whose performance benefit is a side effect.
Two further issues surfaced in the same profile:
1. `visible` was recomputed on every parent render, including renders
caused by unrelated state. For 40 rows this is irrelevant. It was
flagged and left alone, because a filter over 40 items is not the
problem and useMemo has its own cost. Fix the thing you measured.
2. ResultRow was wrapped in React.memo with a prop
onSelect={() => select(row.id)}
created inline in the parent. New function identity every render,
so memo compared props, found them different, and re-rendered
anyway: pure overhead. Removed the memo, moved the handler to use
an id from a data attribute at the tbody level.
A React.memo that never returns true is slower than no memo at all, and it is common enough
that "check whether the memo actually memoises" belongs in any React performance review.
Production evidence
React's own documentation states the two reconciliation heuristics explicitly (different types
produce different trees, and keys give children stable identity across renders), and the "Preserving
and Resetting State" page documents position-plus-type as the identity rule, including the
key={userId} reset idiom.
eslint-plugin-react-hooks is maintained by the React team and ships in Create React App's and
Next.js's default configs. The rules-of-hooks rule exists specifically because hook state is
positional, which is documented in the React team's own writing on the hooks implementation.
React 18's StrictMode double-invokes effects in development, documented in the React 18 upgrade guide as a deliberate change to surface effects that are not resilient to being mounted twice, in preparation for state-preserving unmount and remount.
The React Compiler (formerly "React Forget") automates memoisation by analysing component code
and inserting caching, and the React team reported it running in production on Instagram's web
surface at React Conf 2024. That is the clearest signal about manual memo/useMemo/useCallback
practice: the team's own answer to it is a compiler, not more discipline.
List virtualisation libraries (react-window, react-virtualized, TanStack Virtual) exist
because rendering tens of thousands of rows is a mutation-count problem no diff algorithm solves,
and they are the standard answer once a list is large enough that the diff itself is the cost.
The debate
Is the virtual DOM worth it? Not as a performance mechanism, and yes as a programming model. Svelte and Solid compile to direct, fine-grained updates and do less work per change, which is a genuine advantage. The position: pick React for the ecosystem, hiring pool and library depth, not because the reconciler is fast. Anyone who defends React on raw update performance has not measured it against a fine-grained reactive framework.
Should you memoise by default? No. Default to moving state down and passing children through,
and add memo/useMemo where a profile says so. Blanket memoisation adds comparison cost and
allocation, obscures the real hot path, and breaks silently the moment someone passes an inline
object. The counter-argument, that profiling every component is impractical on a large team, is
real, and the answer the React team chose is the compiler rather than a convention.
Are index keys ever acceptable? Yes, under a narrow condition: the list is append-only, never reordered or filtered, and rows hold no DOM or component state. In practice the condition is checked once and then invalidated by the next feature, so "always key by a stable id" is the right default and the exception should be commented with why.
Is useEffect overused? Badly so. Effects are for synchronising with systems outside React.
Derived values should be computed during render, event responses belong in event handlers, and data
fetching belongs in a framework or query library that handles caching, deduplication and race
conditions. The React docs added an entire page, "You Might Not Need an Effect," which is a strong
signal about the observed failure rate.
Should you suppress exhaustive-deps? Almost never. Each suppression is a claim that you know
the closure will never go stale, which survives until someone edits the effect body. The
legitimate cases (a genuinely once-only mount effect, an intentionally latest-value ref) should be
expressed with a ref or the updater form so the lint rule and the intent agree.
Follow-up Q&A
"Walk me through what happens when I call setState."
React schedules an update on that fiber at a priority derived from the calling context. In the render phase it calls your component function, producing a new element tree, and diffs it against the current fiber tree using the two heuristics: a changed type at a position unmounts the subtree, and within a list, children are matched by key or, absent keys, by index. Where props and state are unchanged and nothing in context changed, React bails out and reuses the existing child fibers. The render phase is interruptible in React 18 and must be pure. It then commits: applies DOM mutations, runs layout effects synchronously, paints, and runs passive effects after the paint.
"Why does index-as-key break a list, specifically?"
Because it declares that a row's identity is its position in the array, so when the array is filtered or reordered, React matches the wrong old row to each new row. It updates surviving DOM nodes in place rather than moving them, which slides all the state React does not own, uncontrolled input values, focus, scroll position, animation progress, and all the component state inside the rows, up or down by the number of removed items. Keying by a stable id makes React match the item to itself, so a row that did not change produces zero DOM mutations.
"Why must hooks be called unconditionally?"
Hook state is stored as a linked list on the fiber and looked up by call order, not by name. A hook
inside a condition changes the number of calls between renders, so every subsequent hook reads the
previous render's neighbouring slot: your useState(0) silently starts returning the string that
belonged to a different hook. The rule is a consequence of the storage mechanism, which is why
eslint-plugin-react-hooks can enforce it statically and why there is no "careful" way to break it.
"My effect runs on every render. How do I diagnose it?"
Log the dependency array and compare each element with Object.is against the previous render's.
In practice the culprit is an object, array or function created fresh by a parent and passed down,
because deps are compared by reference. The fix belongs where the value is created, not where it is
consumed: depend on primitives, memoise at the source, or move the fetch into a query library that
keys on a serialised value. Wrapping the consumer in useCallback when the producer keeps
allocating fixes nothing.
"When is React.memo worth it, and when is it a no-op?"
It is worth it when the component is genuinely expensive to render and its props are referentially
stable across parent renders. It is a no-op, and net negative, when any prop is an inline object,
array or arrow function, because the shallow comparison never returns true and you have added
comparison and allocation cost for nothing. Before adding memo, try moving state down so the
expensive component is not in the re-rendering subtree, or passing it as children so its element
is created where it will not be recreated.
"How do you reset a component's state when a prop changes?"
Change its key. <ProfileForm key={userId} /> unmounts and remounts on a different user, which
is the documented idiom. The alternative people reach for, a useEffect that copies props into
state, renders once with stale data, adds a second render, and drifts out of sync in edge cases.
The key approach uses the identity mechanism that already exists.
Common misconceptions
"The virtual DOM makes React fast." It makes React predictable. A DOM mutation costs the same regardless of who issues it, and React does extra work to compute the set.
"A re-render means a DOM update." A re-render calls your function and diffs. If the output is equivalent, there are zero mutations. Chasing re-render counts rather than measured time is how teams add memoisation that makes things slower.
"Index keys are fine because my list has ids anyway." The bug appears the first time the list is filtered or reordered, which is usually a later feature, and it presents as data corruption in the UI rather than as an error.
"React.memo prevents re-renders." It prevents them only if the shallow prop comparison passes,
which an inline object or arrow function defeats every single render.
"Hooks rules are a style preference." They are a consequence of positional storage. Breaking them reads a different hook's slot.
"useEffect is where you fetch data." It is where you synchronise with things outside React.
Fetching there means hand-rolling caching, deduplication, race protection and cleanup, all of which
a query library or the framework's data layer already does.
Interview delivery note
Say this verbatim: "Keys are a correctness feature, not a performance one. Index keys tell React that a row's identity is its position, so filtering the list slides every piece of state React does not own, uncontrolled inputs, focus, animations, onto the wrong row. Keying by id makes React match an item to itself, and the reduced mutation count is a side effect." It reframes the most common React question away from the performance answer everyone gives.
The senior-versus-staff separator is explaining hooks rules from the storage mechanism. A senior
engineer says hooks must be called at the top level and not in conditions. A staff engineer says
hook state is a linked list on the fiber indexed by call order, so a conditional hook shifts every
subsequent slot and your useState(0) starts returning the previous hook's value, which is why the
rule is mechanical and why a linter can enforce it. Reasoning from the implementation to the rule,
rather than reciting the rule, is the signal.
The second signal is refusing to memoise reflexively. Saying "before React.memo, I would move
the state down or pass the subtree as children, because those cost nothing and cannot silently
stop working, and I would check that any existing memo actually returns true rather than comparing
a fresh inline object every render" demonstrates that you have debugged this rather than read about
it.
Further reading
- React documentation, "Preserving and Resetting State" and "Render and Commit," for the position-plus-type identity rule and the two-phase model.
- React documentation, "You Might Not Need an Effect," for the derived-state and event-handler cases that should not be effects.
- The React 18 upgrade guide's section on StrictMode double-invoking effects, and why.
eslint-plugin-react-hookssource and rule documentation, for the static enforcement of the positional-storage constraint.- The INP diagnosis page in this chapter, where render cost becomes a user-visible metric.