Skip to main content

Reconciliation in React

Reconciliation is React's process of diffing a new virtual DOM tree against the previous one to find the minimal set of DOM mutations needed.

Theory

TL;DR

  • Think of a video editor spotting only the frames that changed and re-rendering just those pixels, not the whole movie.
  • React uses O(n) heuristic diffing instead of the exact O(n³) algorithm by betting on two things: different element types mean different subtrees, and key props identify stable list items.
  • Different type (<div> to <span>) means full unmount and rebuild. Same type means diff attributes, recurse into children.
  • Lists without stable key props fall back to index-based matching, which breaks on reorder or delete.
  • Profile with React DevTools Profiler before reaching for React.memo or tweaking keys.

Quick example

jsx
// Without stable keys: React remounts ALL items on any list change function BadList({ items }) { return ( <ul> {items.map(item => ( <li key={Math.random()}>{item.name}</li> // new key each render = full remount ))} </ul> ); } // With stable keys: only the changed item updates function GoodList({ items }) { return ( <ul> {items.map(item => ( <li key={item.id}>{item.name}</li> // React tracks by id, reuses DOM nodes ))} </ul> ); }

Delete one item from BadList and every <li> remounts, losing focus and local state. Delete from GoodList and only that node disappears. That is the difference.

How the diffing heuristic works

The exact algorithm for comparing two arbitrary trees runs in O(n³). React brings this down to O(n) with two bets it makes about your code.

First bet: elements of different types produce completely different subtrees. So React tears down the old one and builds fresh. A <div> becoming a <span> at the root means every child inside gets unmounted. componentWillUnmount fires. All component state is gone.

Second bet: the developer signals list item identity using key. Without it, React compares by position. Add an item to the beginning of a list and React thinks position 0 changed, position 1 changed, and so on. It re-renders all of them.

Same-type elements follow a different path. React keeps the existing DOM node, diffs only the attributes that changed, then recurses into children. For components, the instance stays alive and state is preserved. Only props update.

jsx
// Old <div className="card" title="old title" /> // New <div className="card" title="new title" /> // React touches only title. className is unchanged.

When to use keys

Reordering or filtering a list: every .map() element needs a stable key from your data, not from its current position.

Switching between two different component types: accept the full remount. There is no way around it, and that is usually fine.

Toggling between two components of the same type: React reuses the instance and preserves state. If you want a fresh mount, give each a different key.

Dynamic list with add or delete: key={item.id} is the only safe option. Index shifts when you remove from the middle.

How React runs this internally

When setState triggers a re-render, the react-reconciler package starts from the affected fiber node and runs reconcileChildren. Fiber architecture, which landed in React 16 and got extended in React 18, made reconciliation interruptible. Instead of one synchronous pass through the tree, React can break the work into chunks using a Scheduler with priority lanes.

React 18 Concurrent Mode adds useTransition, which marks some updates as low-priority. The Scheduler yields to urgent paints and defers the low-priority diff. The algorithm itself is the same. What changes is when it runs.

After diffing, React collects all mutations into an effect list and flushes them to the real DOM in a single commitRoot call. That batch keeps reflows minimal.

Common mistakes

1. Array index as key

jsx
// Delete from the middle and all indices shift. // React thinks items changed when they only moved. {todos.map((todo, index) => ( <TodoItem key={index} todo={todo} /> ))} // Fix: use the id from your data {todos.map(todo => ( <TodoItem key={todo.id} todo={todo} /> ))}

2. Random value as key

jsx
// Math.random() on every render = every element remounts every render {items.map(item => <li key={Math.random()}>{item.name}</li>)} // Fix: stable id generated once when the item is created {items.map(item => <li key={item.id}>{item.name}</li>)}

3. Unnecessary key on a single conditional child

jsx
// key change on a conditional child causes unmount/remount on every toggle function Switcher({ active }) { return ( <div> {active ? <ExpensiveChart key="chart" /> : <Summary key="sum" />} </div> ); } // ExpensiveChart loses its WebGL canvas context on every switch. // Fix: omit key on single conditional children function Switcher({ active }) { return ( <div> {active ? <ExpensiveChart /> : <Summary />} </div> ); }

4. Changing element type on a condition

jsx
// isLoading flip forces full unmount because div !== span function Status({ isLoading }) { if (isLoading) return <div>Loading...</div>; return <span>Done</span>; } // Fix: keep the same element type function Status({ isLoading }) { return <div>{isLoading ? 'Loading...' : 'Done'}</div>; }

5. Assuming reconciliation always preserves state

Any time a key changes or an element type changes, React unmounts and loses state. I've seen teams use a changing key to "reset" a form, not realizing it also killed in-flight animations and WebSocket subscriptions. If you want to reset only form fields, handle that explicitly inside the component instead.

Real-world usage

  • React core: react-reconciler in the facebook/react repo handles all renders. You can also use it standalone to target custom environments like canvas or native.
  • Next.js app router: client-side navigation diffs the component tree via reconciliation. React Server Components send a serialized tree that the client reconciler merges with what is already mounted.
  • Remix: form submissions trigger a re-render pass through the same reconciliation algorithm.
  • React DevTools Profiler: shows which components re-rendered, how long they took, and why. Run it before assuming React.memo will help.

Follow-up questions

Q: Why O(n) instead of O(n³)?
A: The general tree diff problem is O(n³). React reduces this with two heuristics: only compare elements at the same tree level, and use keys to match list items by identity rather than position. Those two shortcuts make it O(n) in practice.

Q: What happens when you skip keys in a mapped list?
A: React falls back to position-based matching. Remove the first item and every item below it looks "changed" by position, so they all re-render or remount depending on whether content or type differs.

Q: What is the difference between the stack reconciler and Fiber?
A: The original stack reconciler processed the entire tree synchronously and could not be interrupted. Fiber, shipped in React 16, uses a linked-list-based work loop that supports pausing, resuming, and aborting work mid-pass. That is what enables Concurrent Mode in React 18.

Q: How does useTransition affect reconciliation in React 18?
A: It marks an update as low-priority. The Scheduler uses a lanes model to defer that diff until after urgent frames are painted. The reconciliation algorithm runs exactly the same way, just later.

Q: Can you force a remount without changing element type or position?
A: Change the key. React treats a different key as a completely new element, unmounts the old instance, and mounts a fresh one. Useful for resetting a component, but watch for side effects like lost subscriptions.

Examples

Basic: key vs no key in a todo list

jsx
function TodoList({ todos }) { return ( <ul> {todos.map(todo => ( // Stable id key: React matches this todo across renders. // Filter, reorder, or add - local state inside TodoItem survives. <TodoItem key={todo.id} todo={todo} /> ))} </ul> ); } function TodoItem({ todo }) { const [editing, setEditing] = useState(false); return ( <li> {editing ? <input defaultValue={todo.text} /> : todo.text} <button onClick={() => setEditing(!editing)}>Edit</button> </li> ); } // Without key={todo.id}: toggle edit on one item, then add another. // All TodoItem components remount and the open input loses cursor position. // With key={todo.id}: only the new todo renders; others keep their state.

The editing state lives inside each TodoItem. With a stable key, React knows which instance maps to which todo and preserves it. Without a stable key, React remounts everything.

Middle: filtered list and input focus

jsx
function UserList({ users, filter }) { const visible = users.filter(u => u.name.includes(filter)); return ( <ul> {visible.map(user => ( <li key={user.id}> {user.name} <input placeholder="Note about user" /> </li> ))} </ul> ); } // With key={user.id}: type a note for Alice, change the filter, // then bring Alice back. Her input value is still there. // // With key={index}: changing the filter shifts indices. // Alice disappears at index 0, Bob moves to index 0. // React reuses the DOM node from Alice's row for Bob - Alice's note appears under Bob.

This is the bug that makes filter inputs feel broken. The fix is one prop. The root cause is React comparing by position when there is no key to compare by identity.

Senior: intentional remount via key for form reset

jsx
// Intentional key change to get a clean component on userId change function ProfilePage({ userId }) { return <ProfileForm key={userId} userId={userId} />; } function ProfileForm({ userId }) { const [name, setName] = useState(''); useEffect(() => { fetchUser(userId).then(user => setName(user.name)); }, [userId]); return ( <form> <input value={name} onChange={e => setName(e.target.value)} /> <button type="submit">Save</button> </form> ); } // When userId changes, key changes. // React unmounts the old ProfileForm and mounts a fresh one with empty state. // No need to manually reset each field in a useEffect. // // Tradeoff: also unmounts any in-progress animations or subscriptions inside. // Use this pattern when a clean slate is exactly what you want.

This pattern comes up constantly in admin dashboards where clicking between records should give you a completely fresh form. It is simpler than resetting a dozen fields manually, but document why the key is there or the next developer will remove it.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?