Understanding React Reconciliation: Fiber, Diffing, and Keys
Introduction
When React state changes, it must determine what actually changed in the UI and apply minimal DOM updates. This process is reconciliation—comparing the new element tree with the previous one and deciding which nodes to create, update, move, or delete.
Understanding reconciliation explains why keys matter, why conditional rendering can reset state, and how Concurrent React can interrupt work. This article covers the diffing algorithm, Fiber nodes, keys, batching, and practical implications for component design.
Table of Contents
- Elements vs Components vs DOM Nodes
- The Reconciliation Algorithm
- Keys and List Reordering
- React Fiber Architecture
- Batching and Updates
- Concurrent Rendering
- Best Practices
- Common Mistakes
- Interview Questions
- Summary
- References
Elements vs Components vs DOM Nodes
A React element is a plain object describing UI:
const element = <h1 className="title">Hello</h1>;
// { type: 'h1', props: { className: 'title', children: 'Hello' } }
Components are functions or classes that return elements. React builds a tree of elements (virtual tree), then maps component instances to Fiber nodes that track DOM references and work queues.
Changing props on the same component type updates in place. Changing type (div → span, Counter → Timer) tears down the old subtree and mounts new.
The Reconciliation Algorithm
React's diff assumes:
- Different element types produce different trees.
- Developers can hint at stable identity with
keyamong siblings.
For children, React iterates both lists. Without keys, it matches by index—which breaks when order changes.
// Before: [A, B, C]
// After: [C, A, B]
// Without keys: React may update A→C, B→A, C→B (three updates)
// With stable keys: React moves nodes (cheaper when structure allows)
Sibling reconciliation is O(n) with heuristics—not a full tree diff like some academic algorithms.
Keys and List Reordering
function TodoList({ todos }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
Bad keys:
key={index} // breaks on reorder/delete
key={Math.random()} // remounts every render
key={todo.title} // collisions if titles duplicate
When key changes, React treats it as a different item—state resets, effects re-run.
React Fiber Architecture
Fiber replaced the old stack reconciler (React 16+). Each Fiber node represents a unit of work:
- Component type and props
- Link to DOM node
- Child, sibling, return pointers (tree as linked list)
pendingPropsvsmemoizedProps- Effect tags for DOM mutations
Work can be paused, resumed, and prioritized. High-priority updates (user input) can interrupt low-priority rendering (large list).
Render phase (pure): build work-in-progress tree, diff
Commit phase (may touch DOM): apply mutations, run layout effects, paint
Batching and Updates
React 18 batches state updates in event handlers, promises, timeouts, and native events by default:
function handleClick() {
setCount((c) => c + 1);
setFlag((f) => !f);
// One re-render, not two
}
flushSync forces synchronous commit when needed (measurements, third-party libs):
import { flushSync } from "react-dom/client";
flushSync(() => setCount(1));
// DOM updated before next line runs
Concurrent Rendering
startTransition marks updates as non-urgent:
import { useState, startTransition } from "react";
function Search({ query }) {
const [results, setResults] = useState([]);
const [filter, setFilter] = useState("");
function handleChange(e) {
setFilter(e.target.value); // urgent: input stays responsive
startTransition(() => {
setResults(expensiveSearch(e.target.value)); // can be interrupted
});
}
return (
<>
<input value={filter} onChange={handleChange} />
<ResultList items={results} />
</>
);
}
Concurrent features rely on Fiber's ability to discard in-progress work and restart.
Best Practices
- Stable keys from domain IDs, not array index.
- Keep component type stable—don't swap component types to change behavior; use props.
- Structure lists flat when possible—deep nesting complicates diffing.
- Lift state carefully—parent re-renders still reconcile children (memo helps selectively).
- Use fragments to avoid extra DOM wrappers without affecting semantics.
- Understand remounting—changing
keyon a component is intentional reset.
Common Mistakes
- Index keys in sortable/filterable lists—wrong item keeps state after reorder.
- Conditional wrapper changing tree shape—
<div>{cond && <A/>}</div>vs{cond ? <A/> : <B/>}. - Deriving keys from unstable data—hash of object that changes every fetch.
- Assuming React diffs by DOM position globally—diff is per sibling list.
- Ignoring double render in Strict Mode—development-only remount for effect safety.
Interview Questions
- What is reconciliation vs rendering?
- Why are keys important? What happens with index keys?
- Explain Fiber and how it enables concurrency.
- When does React unmount vs update a component?
- Difference between render and commit phases?
- How does React 18 automatic batching work?
- What triggers a re-render in React?
- Explain
startTransitionuse cases.
Strict Mode and Double Mounting
React Strict Mode in development intentionally double-invokes certain lifecycles and effects to surface side effects:
useEffect(() => {
const sub = subscribe(userId);
return () => sub.unsubscribe();
}, [userId]);
If cleanup is missing, subscriptions leak—Strict Mode exposes this by mount → unmount → remount in dev. This is not a reconciliation bug; it is a development aid.
Production builds do not double-mount—do not disable Strict Mode to "fix" effect duplication without fixing cleanup.
Reconciliation and Keys in Portals
Portals (createPortal) render children into a different DOM node but remain in the same React tree. Reconciliation still applies—keys in lists inside modals matter identically.
Moving a component between portal and parent changes DOM location but preserves Fiber identity if type and key match.
Fiber Commit Phases in Detail
The commit phase splits into:
- Before mutation —
getSnapshotBeforeUpdate,useLayoutEffectscheduling - Mutation — DOM insert/update/delete
- Layout —
useLayoutEffectruns synchronously after DOM updates - Passive —
useEffectruns asynchronously after paint
Understanding this order explains why useLayoutEffect can measure DOM before paint but blocks painting—use sparingly.
Legacy Stack Reconciler Context
Before Fiber, React used stack-based reconciler—synchronous, could not pause. Understanding history explains why class component lifecycle maps to effect patterns today:
| Class lifecycle | Hooks equivalent |
|---|---|
| componentDidMount | useEffect(fn, []) |
| componentDidUpdate | useEffect(fn, [deps]) |
| componentWillUnmount | useEffect cleanup |
Fiber enabled incremental rendering that stack model could not support.
Testing Reconciliation Behavior
React Testing Library encourages testing user-visible outcomes—not internal Fiber state:
const { rerender } = render(<List items={[{ id: 1, text: "A" }]} />);
rerender(<List items={[{ id: 2, text: "B" }]} />);
// With wrong keys, same DOM node might show stale state
expect(screen.getByText("B")).toBeInTheDocument();
Key-related bugs surface in integration tests with reorder simulations.
When debugging reconciliation issues in large apps, reproduce with minimal component tree—isolate whether bug is key-related, context-related, or effect-related before searching the entire codebase. Time spent narrowing reproduction saves hours of printf debugging in production builds minified beyond recognition.
Summary
Reconciliation is React's diff strategy: compare element trees, respect keys among siblings, and commit minimal DOM changes. Fiber makes this work incremental and interruptible. Correct keys and stable component structure prevent subtle state bugs and unnecessary work—foundational knowledge for any serious React developer.