Rendering and Reconciliation
Rendering computes a new tree; reconciliation finds the minimal changes. Watch index keys move typed input to the wrong row, fix it with stable IDs, then see Fiber's double-buffered trees swap on commit.
🎯 What you'll learn
- What rendering means and what triggers it
- Reconciliation and why index keys are usually wrong
- Render vs commit and the child-matching algorithm
- Fiber nodes and double buffering
📚 Before you start
1What "Rendering" Means in React
"Rendering" is React calling your component functions to find out what they want the UI to look like right now. It does not mean "writing to the DOM" — that's a separate, later step called committing (see section 5 and the "React fundamentals" topic's Under the Hood section). A component can render many times without a single DOM node changing, if the output happens to be identical to last time.
function Clock({ time }: { time: string }) {
console.log("Clock rendering"); // this runs on EVERY render, even if time is unchanged
return <p>{time}</p>;
}2What Triggers a Render
Three things cause React to render a component:
- State changes (
useState/useReducersetter called) on that component or an ancestor. - Props changes — usually because the parent re-rendered and passed new values.
- Context changes — a
Providerwhosevaluechanged re-renders every consuming descendant (see "UseContext").
Note: a parent re-rendering, by default, re-renders every child in its subtree, regardless of whether that child's own props actually changed — React re-runs the child function again to find out what it returns, then diffs the result. ("Performance optimization" covers React.memo, which changes this default.)
3Reconciliation: Comparing Old and New Trees
Every render produces a brand-new tree of React elements (plain objects). Reconciliation is the algorithm React uses to compare this new tree against the previous one and figure out the minimal set of real DOM operations needed to update the screen to match.
A naive tree-diff algorithm is O(n³) for arbitrary trees — far too slow for UI updates. React's reconciler instead uses a set of heuristics that make the comparison O(n), at the cost of being not-perfectly-optimal in some edge cases (documented as trade-offs, not bugs):
Heuristic 1: Different element types → tear down and rebuild
// Before:
<div><Counter /></div>
// After:
<span><Counter /></span>Because the root element type changed (div → span), React doesn't try to patch the div into a span. It destroys the old div subtree entirely (including unmounting Counter — losing its state) and builds a brand-new span subtree from scratch. Changing an element's type at a given position is always a full remount of everything below it.
Heuristic 2: Same element type → keep the DOM node, diff the attributes
// Before:
<div className="a" title="old" />
// After:
<div className="b" title="old" />Same type (div) at the same position → React keeps the existing DOM node and only patches the attributes that actually differ (className here) — title is left untouched since it's identical.
Heuristic 3: Lists are matched by key, not by position
// Before: [{id: "a"}, {id: "b"}]
// After: [{id: "c"}, {id: "a"}, {id: "b"}]
<ul>{items.map((item) => <li key={item.id}>{item.text}</li>)}</ul>Without keys, React would compare position-by-position and think every single item changed (since index 0 went from "a" to "c", index 1 from "b" to "a", etc.), causing three DOM updates. With stable keys, React recognizes "a" and "b" as the same items that simply moved, and only inserts one new node ("c") — a single DOM operation instead of three.
4Why Array Index as key Is Usually Wrong
{items.map((item, index) => <TodoItem key={index} {...item} />)}If items only ever grows at the end and never reorders/removes from the middle, index keys are harmless. But the moment an item is removed or reordered, every subsequent item's key (its index) shifts, and React — matching by key — thinks each of those items is the same component with new props, rather than a different item. Any local state those components hold (an open <details>, an in-progress text input) ends up attached to the wrong data.
// safe: a stable id that travels WITH the data, not with its position
{items.map((item) => <TodoItem key={item.id} {...item} />)}5Render Phase vs Commit Phase, Revisited
Reconciliation (the diffing described above) happens during the render phase — it produces a list of effects (DOM operations to perform, lifecycle/effect callbacks to run) attached to fibers, but performs no actual mutations yet. The commit phase then walks that effect list and applies every mutation to the real DOM in one synchronous pass. This split is why reconciliation itself can be paused/abandoned by React's scheduler without any visible half-updated UI — nothing user-visible has happened until commit runs.
Under the HoodThe Actual Child-Matching Algorithm▾
The three heuristics above are the documented contract. The implementation underneath (reconcileChildFibers in react-reconciler) is more specific, and knowing it explains behaviour that the contract alone doesn't.
The elements being diffed are plain objects, never DOM nodes
<li key="a">x</li> compiles to a jsx("li", { children: "x" }, "a") call, which returns a frozen-in-spirit plain object roughly { $$typeof: Symbol.for("react.element"), type: "li", key: "a", props: { children: "x" } }. Reconciliation is therefore a comparison of two object graphs on the JS heap — the DOM is not read at all during the render phase. This is why the same reconciler drives React DOM, React Native, and test renderers unchanged: nothing in it knows what a DOM node is.
Single child: the fast path
When the new children are a single element (not an array), React runs a loop over the existing child fibers looking for one whose key and type both match. Key is compared first: a key mismatch means "different item, delete it and keep looking"; a key match with a type mismatch means "same slot, but incompatible" — React deletes that fiber and all its siblings and creates a fresh one. That "delete the remaining siblings too" step is exactly Heuristic 1 at the implementation level, and it is why swapping <div> for <span> in one JSX slot throws away everything below it.
Array children: a two-pass algorithm, not a full diff
reconcileChildrenArray does this:
- First pass — walk both lists in lockstep from index 0 while the keys line up. As long as
oldFiber.key === newChild.key, React updates in place and advances both cursors. For a list that only had items appended or edited (no reordering), this pass consumes the entire list and the algorithm is done — pure O(n), no map allocated. - When the keys stop lining up, React builds a
Mapfrom the remaining old fibers, keyed byfiber.key ?? fiber.index. It then walks the rest of the new children, and for each one looks up its key in that map: a hit means "this item already exists, reuse its fiber (and its state, and its DOM node)"; a miss means "brand new, create a fiber." - Anything still left in the map at the end never appeared in the new children, so it is marked for deletion.
lastPlacedIndex: how React decides what actually moved
Reusing a fiber is not the same as leaving its DOM node alone. React tracks a running lastPlacedIndex — the highest old-list index it has placed so far. For each matched child it compares the old fiber's index against it:
oldIndex < lastPlacedIndex→ this node has moved backwards relative to already-placed siblings, so React sets thePlacementflag on it, and the commit phase willinsertBeforeit.- otherwise →
lastPlacedIndex = oldIndex, and no DOM move is emitted at all.
This is a greedy heuristic, not an optimal-move solver: it finds an increasing subsequence by scanning left to right rather than the longest increasing subsequence. Reversing a 100-item list therefore emits 99 DOM moves, not the theoretical minimum. React trades optimality for a single linear pass — the same bargain as the O(n) tree diff.
Why index keys break state, mechanically
Note what the map is keyed by in step 2: fiber.key ?? fiber.index. When you write key={index}, you are handing React the same value it would have fabricated positionally anyway, so the map lookup succeeds for the wrong item after a removal. React finds a fiber under key 1, concludes "this is the same component," and keeps that fiber's memoizedState — its whole hooks linked list — while feeding it the next item's props. The DOM node is reused, the internal state is reused, only the props are swapped. Nothing warns you, because from React's point of view every rule was followed. Stable ids that travel with the data make the key identify the item, not the slot.
⚠️ Common Pitfalls
- Using
key={index}on a list that reorders, filters, or deletes — the key names the slot rather than the item, so React reuses the previous occupant's fiber (and itsmemoizedState) for a different piece of data. Uncontrolled inputs, scroll positions, and open/closed toggles all end up attached to the wrong row. - Using
key={Math.random()}to "fix" key warnings — a fresh key every render means no old fiber ever matches, so every item is unmounted and recreated on every render: maximum DOM churn, total state loss, and effects re-firing constantly. It silences the warning by making the problem as bad as it can be. - Putting the
keyon the wrong element — the key must be on the element returned directly by the.map()callback. Putting it on an inner child (<><li key={id}/></>or on the root of a wrapper's JSX) leaves the mapped element unkeyed and the whole array falls back to index matching. - Assuming a re-render means the DOM was written to — reconciliation frequently concludes "nothing to do" and emits no flags at all; the component function still ran. Profiling "re-renders" and "DOM mutations" are two different measurements.
- Conditionally changing element type in a fixed slot (
cond ? <div>{x}</div> : <section>{x}</section>) — the type mismatch deletes the fiber and every descendant fiber, remounting the entire subtree each time the condition flips. Rendering the same type with a differentclassNameavoids the teardown entirely. - Expecting the diff to detect a subtree that moved to a new parent — matching only ever happens among siblings under the same parent fiber. Move a node to a different parent and it is a delete plus an insert, with full state loss, no matter how stable the key is.
📋 Quick Reference
render = call component functions, build new element objects, diff → set flags
commit = walk flagged fibers, apply DOM mutations, run effects
Matching rules (siblings only, never across parents):
key differs → delete old fiber, create new one
key same, type differs → delete old fiber + its siblings, create new
key same, type same → reuse fiber, reuse DOM node, patch changed props only
no key given → key falls back to the array index
Move detection:
oldIndex < lastPlacedIndex → flag Placement (DOM insertBefore at commit)
oldIndex >= lastPlacedIndex → lastPlacedIndex = oldIndex, no DOM move
key={item.id} ✅ identifies the item
key={index} ⚠️ identifies the slot — safe only for append-only lists
key={Math.random()} ❌ never matches: full remount every render1Why React Rewrote Its Reconciler Around Fiber
Before React 16, reconciliation walked the element tree recursively with the plain JS call stack — once started, a render could not be paused, because there was no way to save "where you were" in a native call stack and resume later. A large tree could block the main thread for tens of milliseconds, dropping frames and freezing input. React 16 introduced Fiber: reconciliation re-implemented as an explicit, resumable data structure and a work loop that processes it unit-by-unit, so React itself controls when to pause, yield to the browser, and resume — instead of the JS call stack controlling that implicitly.
2The Fiber Node
A fiber is a plain JS object, one per component instance / host (DOM) element, that acts as both a unit of work and a persistent record of that instance across renders. Simplified shape:
Fiber {
type: Function | string // the component function, or "div"/"span"/etc.
key: string | null
stateNode: Instance | null // the actual DOM node, or class instance
child: Fiber | null // first child
sibling: Fiber | null // next sibling
return: Fiber | null // parent ("return" because it's where to go
// when this fiber's work is done)
pendingProps: any // props for the render in progress
memoizedProps: any // props from the last completed render
memoizedState: any // hooks linked list (see "State" topic)
alternate: Fiber | null // pointer to the OTHER tree's version of this fiber
flags: number // bitmask: what effects to apply on commit
lanes: number // bitmask: priority of pending work
}Fibers link via child/sibling/return instead of a children array — this lets the work loop walk the entire tree with simple pointer-following (depth-first: go to child, then sibling, then back up via return) without recursion, which is what makes pausing/resuming possible: the "call stack" is just these pointers, stored on the heap, not the JS engine's real stack.
3Double Buffering: Two Trees, Not One
React keeps two fiber trees alive at once:
- current tree — what's actually on screen right now.
- work-in-progress (WIP) tree — being built/updated for the next render, node by node, reusing as much of the current tree's fiber objects as it can (cloning only what actually needs new
pendingProps).
Each fiber's alternate pointer links it to its counterpart in the other tree. When the WIP tree finishes and commits, React simply flips a single pointer (FiberRoot.current) to point at the WIP tree — it becomes the new "current" tree, and the old current tree becomes available to reuse as the next WIP tree. This is called double buffering, the same technique used in graphics rendering to avoid ever showing a half-drawn frame: the screen only ever reflects one complete, finished tree, never a tree that's partway through being built.
4The Work Loop and the Scheduler
React's work loop processes one fiber at a time:
while (workInProgress !== null && !shouldYieldToBrowser()) {
workInProgress = performUnitOfWork(workInProgress);
}shouldYieldToBrowser() is what makes this cooperative and interruptible: after each fiber, React checks whether it has used up its time slice (roughly 5ms, tuned to fit inside a frame budget) and, if so, returns control to the browser so it can handle input, paint, etc., then resumes the loop later where it left off. This is the mechanical basis for React's concurrent features — startTransition, useDeferredValue — which mark certain updates as low priority so urgent work (like text you're actively typing) can interrupt and go first.
lanes (a bitmask on each fiber and each update) is how React tracks priority — a setState from a click handler gets a different lane than one wrapped in startTransition, and the scheduler picks which lanes' work to process first when there's contention. This entire lane/priority system is invisible if you never touch concurrent APIs — by default, updates are processed essentially synchronously in priority order — but it's the reason those APIs are even possible without a separate reconciler.
5Effect Tags and the Commit Phase
During the render phase, as React processes each fiber, it doesn't touch the DOM — it sets bits on that fiber's flags field (e.g. Placement, Update, Deletion) recording what will need to happen to the corresponding real DOM node. After the whole WIP tree finishes, the commit phase walks the fibers carrying non-zero flags (React tracks this as a subtree-completion list, so it doesn't have to re-walk fibers with no work) and performs the actual appendChild/removeChild/attribute updates synchronously, then flips the current pointer as described above, then runs layout effects, then (after the browser paints) passive effects.
Under the HoodWhat setState Actually Does▾
setCount(5) does not set anything. It is a scheduling call. Here is the real sequence.
Step 1 — an update object is queued on the hook, not on the component
Every useState hook owns an UpdateQueue with a pending field that holds a circular singly-linked list of updates. Calling the setter creates an update object and splices it into that ring:
update = {
lane: requestUpdateLane(fiber), // priority bucket for this update
action: 5 | (prev) => prev + 1, // the value or updater function
eagerState: null,
hasEagerState: false,
next: <the next update in the ring>
}The ring is why a functional updater like setCount(c => c + 1) composes correctly when called three times in a row: three separate update objects sit in the queue, and at render time React replays them in order against the previous state. Calling setCount(count + 1) three times instead queues three updates whose action is the same already-captured number, so the last one wins. That is not a batching quirk — it is closure capture (see the JavaScript Closures topic) meeting a replay queue.
Step 2 — the eager-state bailout
If the queue is empty and the fiber currently has no pending work, React eagerly computes what the next state would be, right there in the setter, and stores it as eagerState. It then compares it against the current state with Object.is. If they match, React returns without scheduling anything at all — this is why setTheme("dark") when the theme is already "dark" costs nothing. If the state does differ, that eagerly computed value is reused at render time rather than recomputed.
Beware the near-miss: this bailout happens before React knows about later updates, so it can only fire on the first setter call of a batch. And the eager comparison is Object.is, so setItems([...items]) with identical contents is always a different reference and always schedules.
Step 3 — lanes are marked up the tree
React then walks from the updated fiber up through return pointers to the FiberRoot, OR-ing this update's lane bit into every ancestor's childLanes. This is the mechanism that lets the render phase skip work: when the reconciler reaches a fiber whose lanes and childLanes contain nothing for the lanes currently being rendered, it can bail out and clone the entire existing subtree without calling a single component function. Without those ancestor marks, React would have to re-render from the root down every time.
The lane is chosen from context: a click handler gets SyncLane, an update inside startTransition gets a transition lane, one from a useDeferredValue or an offscreen render gets a lower one. Lanes are bits in a 31-bit mask specifically so React can test membership, merge sets, and find the highest-priority pending set with bitwise operations rather than list scans.
Step 4 — the render is scheduled, not performed
Finally React calls ensureRootIsScheduled(root). This is where automatic batching actually lives, and it is simpler than most explanations suggest: the function checks whether a callback is already scheduled for this root at this priority, and if so it does nothing. So ten setState calls in one click handler queue ten update objects across possibly several fibers, mark lanes ten times, and schedule exactly one render callback. React then renders once, replaying all queued updates.
Since React 18 this holds regardless of where the setters were called — event handler, setTimeout, a .then(), a native event listener — because batching is a property of "one scheduled callback per root," not of being inside React's synthetic event system. React 17 only batched inside its own event handlers, which is why the older mental model ("async code isn't batched") stopped being true.
The scheduled work itself is posted through the scheduler package, which uses a MessageChannel — not setTimeout — to get a macrotask that fires without the 4ms clamp browsers apply to nested timers (see the JavaScript Event Loop topic for why that clamp exists). flushSync(fn) is the escape hatch: it forces the render and commit to run synchronously before flushSync returns, which is occasionally necessary for measuring the DOM, and is otherwise a performance mistake because it defeats batching by design.
Why this is the only design that could work
Suppose setCount mutated state and re-rendered immediately. Then a handler doing setA(1); setB(2) would render once with a=1, b=<old> — an intermediate state that never existed in your model and that effects and children would observe. Deferring means the component function is only ever called against a consistent snapshot. It also means an update can be assigned a priority and possibly abandoned mid-render (concurrent rendering), which is impossible if the mutation already happened.
⚠️ Common Pitfalls
- Assuming reconciliation always finds the theoretically minimal diff — it uses O(n) heuristics (type + key matching), not a true minimal tree-edit-distance algorithm; certain restructurings (e.g. moving a subtree to a completely different parent) are not detected as a "move" and get torn down/rebuilt instead.
- Changing a component's element type conditionally in the same position (
condition ? <div/> : <span/>in the same JSX slot) — causes a full unmount/remount of everything below it every time the condition flips, including losing all descendant state. - Confusing "re-render" with "DOM update" — a component re-rendering (its function running again) does not necessarily mean the DOM changes; reconciliation may find the output identical and skip the commit for that subtree's unaffected nodes.
- Not realizing a parent's re-render cascades to all children by default — every child in the subtree gets its function called again unless memoized (see "Performance optimization"); this is expensive work-phase cost even when no DOM changes result.
- Reading state right after calling its setter — the setter only queued an update object; the
countbinding in the current scope is aconstcaptured by this render's closure and can never change. Read the new value in the next render, or compute it locally before you call the setter. - Reaching for
flushSyncto make a value "available immediately" — it forces a synchronous render and commit, defeating batching, and if you call it inside a lifecycle or effect React warns and may still defer. It is for DOM measurement between two updates, nothing else. - Assuming
setStatewith an equal value is always free — the eager bailout only applies when the hook's queue is empty; inside a batch that already queued an update, an identical value still schedules a render (React usually bails out one level later, after re-rendering the component but before touching children). - Mutating state and calling the setter with the same reference —
items.push(x); setItems(items)passes a reference that isObject.is-equal to the current state, so the eager bailout can drop the update entirely and nothing renders. Always produce a new array/object.
📋 Quick Reference
| Term | Meaning |
|---|---|
| Fiber | Persistent JS object per component/host node; unit of work + bookkeeping |
alternate | Pointer linking a fiber to its counterpart in the other (current/WIP) tree |
| Double buffering | Two full fiber trees; commit = flip one root pointer |
| Render phase | Diffing + building WIP tree; interruptible, no DOM writes |
| Commit phase | Applying flags-marked mutations to real DOM; synchronous |
lanes | Priority bitmask driving which pending work gets processed first |
| Key-based list diff | O(n) heuristic matching by key, not full tree-edit-distance |
setState | Queues an update object on the hook's circular queue; schedules, never mutates |
| Eager bailout | First setter call of a batch computes next state early; Object.is-equal → no schedule |
childLanes | Ancestor lane marks that let the render phase skip untouched subtrees |
| Automatic batching | ensureRootIsScheduled dedupes: N setters in one tick → 1 render callback |
flushSync(fn) | Forces synchronous render + commit; defeats batching, for DOM measurement only |
setCount(c => c + 1) x3 → 3 update objects, replayed in order → +3
setCount(count + 1) x3 → 3 update objects, same captured value → +1🧠 Key takeaways
- Renders happen when state, props (parent render) or context change.
- Reconciliation compares element type and key; different type → remount, same key → update.
- Index keys attach state to positions; use stable IDs for dynamic lists.
- Fiber keeps current and work-in-progress trees; rendering can pause, and commit swaps them atomically.
🎤 Interview Q&A
Q1What triggers a re-render in React?core▾
A state update in the component, a parent re-rendering (children re-render by default), or a consumed context value changing. Re-rendering means calling the component function again; it doesn't necessarily change the DOM.
- Do prop changes alone trigger renders? Props change because the parent re-rendered.
Q2How does reconciliation work?core▾
React compares the new element tree with the previous one: different element types at a position → unmount and remount; same type → update props and recurse; lists matched by keys. This heuristic makes diffing O(n).
- Virtual DOM? The element tree React diffs before committing minimal DOM changes.
Q3What are the render and commit phases?core▾
Render: React calls components to compute the new tree (pure, interruptible in concurrent rendering). Commit: applies DOM changes, then runs layout effects, then (asynchronously) passive effects.
- Why must render be pure? React may call it multiple times or discard results (StrictMode double-invokes in dev).
Q4What is concurrent rendering?senior▾
React 18 can interrupt, pause and prioritize rendering work: urgent updates (typing) stay responsive while transitions (startTransition, useDeferredValue) render in the background.
- What's Fiber? React's internal architecture of work units enabling interruptible rendering.
Q5How do you find unnecessary re-renders?core▾
React DevTools Profiler (highlight updates, flame chart, why-did-this-render), then fix with state colocation, splitting components, memoization (React.memo, useMemo, useCallback), or the React Compiler.
- Should you memoize everything? No; measure first.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.