React Rendering and Reconciliation: A Deep Dive into the Fiber Engine

If you’ve ever wondered:
- “What actually happens when I call
setState?” - “How does React decide what to re-render and what to skip?”
- “What’s this ‘Fiber’ thing people talk about?”
…this post is meant to be the final piece in your mental model of React rendering.
We’ll go under the hood and walk through:
- A high-level mental model of React’s rendering pipeline
- A historical note on how React rendered before Fiber
- The Fiber architecture: how React represents work internally
- The rendering phases: render (reconciliation) vs commit
- The flow from
setStateto DOM update - How the reconciliation algorithm works for elements and lists
- How React decides when to bail out or re-render
- How effects (
useEffect,useLayoutEffect) tie into commits - A concrete step-by-step render example
- Lanes: how React decides the priority of updates
- How concurrent rendering builds on top of fibers and lanes
- A full recap to tie everything together
1. Mental Model: React as a “Scheduler + Renderer”
At a very high level, modern React (16+) is two things wrapped together:
- A scheduler – decides when to work on updates, and which updates are more urgent.
- A renderer – figures out what the UI should look like, then applies changes to the host (the DOM, React Native, etc.).
When you write React, you mostly describe what the UI should be:
function Counter({ initial }) {
const [count, setCount] = useState(initial);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}React’s job is to:
- Track when
countchanges. - Re-run
Counterto get a new “virtual tree”. - Compare the new tree to the previous one.
- Apply the minimal changes to the DOM.
Steps 2–4 are the rendering and reconciliation pipeline, powered by fibers and lanes.
2. Historical Note: How React Rendered Before Fiber
Before React 16, React used what is often called the stack reconciler.
At a high level, rendering worked like this:
- React would start at the root component.
- It would recursively call
render()/ function components down the tree. - For every update, React would walk the entire affected subtree synchronously.
- Once started, this work could not be interrupted until it finished.
You can imagine it as a normal JavaScript call stack:
render(App)
-> render(Header)
-> render(Main)
-> render(Counter)
-> render(Footer)This model was simple, but it had two big limitations:
-
No interruption or pausing
If a large tree took 40ms to reconcile, the browser’s main thread was blocked for the full 40ms.
That meant dropped frames and janky interactions on slower devices. -
No granular priorities
A low-importance update (like preloading some data) had the same “all or nothing” behavior as a high-importance update (like a key press).
React couldn’t easily say, “Pause this big render, the user just typed in an input — handle that first.”
As UIs grew more complex and users expected smooth 60fps interactions, this model became too limiting.
Fiber was introduced in React 16 as a new internal architecture to solve exactly these problems:
- Represent work as a linked Fiber tree instead of just a JS call stack.
- Allow React to pause, resume, and even restart rendering work.
- Introduce priorities (lanes) so urgent updates can run before less important ones.
From this point on, when we talk about “rendering”, “reconciliation”, and “lanes”, we’re talking about how this Fiber-based engine works inside modern React.
3. The Fiber Architecture: React’s Internal Data Structure
React doesn’t work directly with your JSX or plain objects during rendering. It compiles them into a tree of Fiber nodes.
3.1 What Is a Fiber?
A Fiber is a JavaScript object that represents a unit of work in React’s tree.
Think of each Fiber node as:
“A component or DOM node + its state + pointers to children/sibling/parent + bookkeeping info.”
Simplified view of a Fiber:
type Fiber = {
type: any; // Component type or DOM tag: 'div', Button, etc.
key: null | string;
pendingProps: any; // Props for the next render
memoizedProps: any; // Props from the last committed render
memoizedState: any; // State from hooks / class component
child: Fiber | null; // First child
sibling: Fiber | null; // Next sibling
return: Fiber | null; // Parent
stateNode: any; // e.g. actual DOM node or class instance
flags: number; // What needs to happen in commit (Placement, Update, Deletion, etc.)
alternate: Fiber | null; // Link to the “other” tree (current vs workInProgress)
// ...plus many more internal fields (lanes, update queues, etc.)
};3.2 Two Trees: Current vs Work-In-Progress
React always deals with two trees:
- The current tree – what’s currently rendered on screen.
- The work-in-progress tree – what React is building for the next render.
They are linked via the alternate field on each Fiber.
We can visualize it like this:
Current Tree Work-In-Progress Tree
------------ ----------------------
RootFiber (current) <-> RootFiber (WIP)
| |
Child Child (WIP)
| |
...When React finishes the render phase successfully, it swaps them:
- The work-in-progress tree becomes the new current tree.
- The old current tree becomes the new work-in-progress for future updates.
This double-buffering is essential for concurrent rendering and the ability to pause/resume work.
4. The Two Phases: Render vs Commit
React’s rendering pipeline is conceptually split into two phases.
4.1 Render Phase (Reconciliation)
- Can be interrupted, paused, or restarted (in concurrent mode).
- Purely computes what the next UI should look like.
- Builds or updates the work-in-progress Fiber tree.
- No DOM mutations happen here.
4.2 Commit Phase
- Cannot be interrupted (must be fast).
- Applies all side effects:
- DOM mutations (insert, update, delete nodes)
- Runs layout effects (
useLayoutEffect,componentDidMount, etc.) - Attaches refs
Once the commit phase finishes, the work-in-progress tree becomes the new current tree.
A simple diagram:
5. From setState to Render: The Flow
Let’s zoom into what happens when setState (or a state hook) is called.
5.1 A Simple Example
function Counter() {
const [count, setCount] = useState(0);
console.log("render", count);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}When you click the button:
setCountpushes an update into the Fiber’s update queue.- React assigns a lane (priority) to this update and schedules work on the root Fiber.
- The scheduler eventually calls the renderer to perform work:
- It starts from the root Fiber and begins the render phase.
- It walks the tree and re-runs
Counter(and other affected components).
- During this walk, React creates or updates the work-in-progress fibers.
- After the work-in-progress tree is complete, React runs the commit phase to apply changes.
5.2 Render Phase: Depth-First Walk
The render phase is basically a depth-first traversal of the work-in-progress tree:
React visits each Fiber, “renders” it (i.e., calls your component function or render()), and:
- Compares the returned elements to the existing children
- Decides which children to keep, move, insert, or delete
- Sets the appropriate flags on Fibers (Placement, Update, Deletion)
Those flags drive what happens in the commit phase.
6. Reconciliation: How React Diffs Trees
Reconciliation is how React figures out what changed between the previous render and the next.
Key idea:
React uses a set of heuristics to make tree diffing efficient in O(n) time, assuming most of the tree structure stays similar between renders.
We’ll look at:
- Comparing a single child
- Comparing a list of children (where keys matter)
6.1 Single Child Reconciliation
Consider:
function App({ isLoggedIn }) {
return (
<div>
{isLoggedIn ? <Dashboard /> : <Login />}
</div>
);
}In the DOM tree for <div>, there is only one child (either Dashboard or Login).
React’s rules for a single child:
- If the element type is the same and key matches:
- e.g., old:
<Dashboard />, new:<Dashboard />
→ reuse the existing Fiber/DOM node, just update props.
- e.g., old:
- If the element type is different (or keys differ):
- e.g., old:
<Dashboard />, new:<Login />
→ delete the old subtree and mount a new one.
- e.g., old:
In this example, toggling isLoggedIn from true to false means:
- React sees
<Dashboard />replaced with<Login />. - It marks Dashboard’s Fiber with a Deletion flag.
- It creates a new Fiber for Login with a Placement flag.
- Commit phase: removes Dashboard’s DOM, adds Login’s DOM.
6.2 List Reconciliation and Keys
Lists are where reconciliation gets tricky—and where keys become critical.
Example:
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.text}
</li>
))}
</ul>
);
}Let’s say the previous render had:
oldChildren = [
<li key="1">Buy milk</li>,
<li key="2">Walk dog</li>,
<li key="3">Read book</li>,
];New render:
newChildren = [
<li key="2">Walk dog</li>,
<li key="3">Read book</li>,
<li key="4">Write blog</li>,
];React’s heuristic (roughly):
- First pass: match children by index and key until a mismatch is found.
- When mismatch detected, React builds a map of remaining old children by key.
- For each new child:
- If key exists in map → move / reuse that Fiber, update props.
- If not → create a new Fiber (Placement).
- Any old children left in the map after processing new ones → Deletions.
Visually:
Old: [1, 2, 3]
New: [2, 3, 4]
Match '1' vs '2' -> mismatch -> build map {1,2,3}
Process '2' -> reuse old '2' (Update)
Process '3' -> reuse old '3' (Update)
Process '4' -> new node (Placement)
Leftover in map -> '1' (Deletion)Because we used stable key={todo.id}, React can re-use the existing DOM nodes for 2 and 3 and just move them, not remount them.
What If We Used Index as Key?
If you wrote:
<li key={index}>Then deleting the first item changes indexes, so React thinks:
- Index 0: old
[Buy milk]vs new[Walk dog]→ Update, not delete/reuse
It may end up reusing DOM nodes incorrectly; Focus, local state, or animations can behave strangely.
Moral: stable, meaning-based keys (like IDs) let React reconcile lists correctly and efficiently.
7. Bailing Out: When React Skips Rendering
React doesn’t blindly re-render the whole tree every time. It has several ways to bail out early.
7.1 Memoized Props and State on Fibers
Each Fiber stores:
memoizedProps– props from the last commitmemoizedState– state from the last commit (hooks / class state)
During render, React compares:
pendingPropsvsmemoizedPropsupdatequeues vsmemoizedState
If nothing changed and there are no higher-priority updates inside, React can skip the subtree:
const HeavyComponent = React.memo(function HeavyComponent({ data }) {
console.log("HeavyComponent render");
// ...
});If data is referentially equal between renders, React can completely skip re-rendering HeavyComponent.
Under the hood:
- React sees that
pendingProps === memoizedProps(shallow compare forReact.memo). - It marks the Fiber to bail out and reuses the existing child fibers.
7.2 shouldComponentUpdate / PureComponent
In class components:
shouldComponentUpdate(nextProps, nextState)lets you manually decide to skip.React.PureComponentdoes a shallow prop and state comparison by default.
These mechanisms plug into the same bailout logic in the reconciliation algorithm.
8. Render vs Commit: What Happens Where?
Let’s clarify which operations belong to which phase.
8.1 Render Phase
During render, React:
- Walks the Fiber tree (depth-first).
- Calls function components and class
render()methods. - Creates the work-in-progress tree.
- Calculates the side-effect flags for each Fiber (Placement, Update, Deletion).
No DOM mutations, no layout measurement, no useEffect callbacks run here.
8.2 Commit Phase
During commit, React:
- Before mutation (rarely used lifecycle hooks).
- Mutation:
- Insert new DOM nodes
- Update DOM attributes and text
- Remove DOM nodes
- Layout and effect:
- Run
useLayoutEffectcallbacks andcomponentDidMount/componentDidUpdate - Set refs
- Schedule
useEffectcallbacks to run after painting
- Run
Timeline diagram:
9. Effects and the Commit Phase
Hooks add another layer to understanding rendering.
9.1 useEffect:
- Runs after the browser paints (as a “passive” effect).
- Scheduled in the commit phase, but executed asynchronously.
- Good for:
- Events subscriptions
- Logging
- Network calls
- Integrations that don’t need to block painting
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => clearInterval(id);
}, []);9.2 useLayoutEffect:
- Runs synchronously after DOM mutations but before the browser paints.
- Can block painting if slow.
- Good for:
- Measuring DOM size/position
- Synchronous layout adjustments
- Imperative UI libraries that must run before paint
Internally, React treats layout effects and passive effects differently in the commit phase, so understanding which one you’re using is important for performance and avoiding flicker.
10. Step-by-Step Example: A Small Tree
Let’s walk through a tiny example to tie this together.
function Header() {
console.log("Header render");
return <h1>My App</h1>;
}
function Counter({ initial }) {
const [count, setCount] = useState(initial);
console.log("Counter render", count);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
function App() {
return (
<div>
<Header />
<Counter initial={0} />
</div>
);
}10.1 Initial Mount
- React mounts
App:- Creates root Fiber (
Appas child ofHostRoot) - Render phase:
- Render
App→ returns<div>…</div> - Render
Header→ returns<h1> - Render
Counter→ returns<button>
- Render
- Flags:
Placementfor all new Fibers.
- Creates root Fiber (
- Commit phase:
- Create DOM nodes for
<div>,<h1>,<button> - Attach them to the DOM.
- Run layout effects (if any).
- Create DOM nodes for
Console:
Header render
Counter render 010.2 On Click (Counter Update)
Clicking the button:
setCountenqueues an update on Counter’s Fiber.- Scheduler schedules work at normal priority (lane-based).
- Render phase (only parts of tree that need it):
- React starts at root and walks down.
Headerprops and state didn’t change → might be bailed out (if memoized).Counterhas an update → re-runCounter:- Returns
<button>with newcounttext.
- Returns
- React compares old vs new
<button>:- Same type
'button'and key → markUpdate, notPlacement.
- Same type
- Commit phase:
- Update the button’s text node.
- Effects if any.
Console:
Header render
Counter render 0
Counter render 1(Depending on StrictMode in dev, you might see double render logs.)
11. Lanes: How React Decides Update Priority
So far, we’ve treated all updates as if they were equal. In reality, React knows that:
- A click or keypress is more important than
- A filtering operation that updates a big list, which is more important than
- A background log or idle update.
Internally, React represents priority using lanes.
11.1 What Are Lanes?
Lanes are:
A set of bit flags where each bit represents a priority bucket.
Imagine a highway:
- Each lane = one priority category (sync, continuous input, default, transition, idle…).
- An update sits in one or more lanes.
- The root keeps a pendingLanes bitmask = union of all lanes with work.
- React always picks the highest-priority non-empty lane to work on next.
Why this design?
- Multiple priorities can coexist (transition + idle + urgent).
- Lanes are easy to combine and compare with bitwise operations.
- React can batch updates but still treat tham differently internally.
You’ll see lane names in React’s source like:
SyncLaneDefaultLaneTransitionLaneIdleLane- and combinations like
SyncLane | InputContinuousLane.
11.2 Where Do Lanes Come From?
React assigns a lane when an update is scheduled, based on how and where the update is triggered.
Roughly:
- Urgent / sync-like lanes → discrete user events:
onClick,onKeyDown,onChange, etc.
- Continuous input lanes →
onScroll,onMouseMove(slightly less urgent). - Transition lanes → updates wrapped in
startTransitionoruseTransition. - Default lanes → normal async updates (e.g., network responses).
- Idle lanes → lowest-priority background work.
Example: a simple urgent update
function Button() {
const [pressed, setPressed] = useState(false);
return (
<button onClick={() => setPressed(true)}>
Click me
</button>
);
}- The
setPressed(true)is triggered inside a click event handler. - React treats this as a high-priority (sync-like) update.
- The UI should respond quickly to this interaction.
Example: mixing urgent and transition updates
import { startTransition } from "react";
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
function handleChange(e) {
const value = e.target.value;
// Urgent: keep the input responsive
setQuery(value);
// Non-urgent: expensive filtering can be deferred
startTransition(() => {
setResults(expensiveFilter(value));
});
}
// ...
}Here:
setQuery(value)→ urgent lane (discrete input).setResults(...)insidestartTransition→ transition lane, which:- Can be delayed, paused, or restarted.
- Will not block typing responsiveness.
11.3 How React Picks a Lane for an Update
Internally, when you call setState, React roughly does:
-
Check context:
- Are we inside a discrete event handler?
- Inside
startTransition? - From a continuous event?
- From a “default” context like a promise resolution or timeout?
-
Based on that, assign a lane:
- Discrete event →
SyncLaneor similar high-priority lane - Continuous event → continuous lane
- Transition scope →
TransitionLane - Default →
DefaultLane - Background →
IdleLane
- Discrete event →
-
Mark that lane as pending on the root:
root.pendingLanes |= lane; -
Ask the scheduler to ensure the root is scheduled for work:
ensureRootIsScheduled(root);
So in very rough pseudo-logic:
function scheduleUpdateOnFiber(updateSource) {
let lane;
if (insideTransitionScope) {
lane = TransitionLane;
} else if (insideDiscreteEvent) {
lane = SyncLane; // or a high-priority lane
} else if (insideContinuousEvent) {
lane = ContinuousLane;
} else {
lane = DefaultLane; // normal async rendering
}
// Mark this lane as pending on the root
root.pendingLanes |= lane;
// Ask the scheduler to process the highest-priority pending lane
ensureRootIsScheduled(root);
}The real React code is more complex, but this is the mental model.
11.4 How React Chooses What to Render Next
When some lanes are pending, React must decide what to do next:
- Look at
root.pendingLanes(bitmask of all lanes with work). - Compute
nextLanes = getNextLanes(root, root.pendingLanes). getNextLanesapplies rules like:- Urgent > default > transition > idle.
- Prefer newer work over older, if they conflict.
- If a high-priority lane appears while low-priority work is in progress, React can:
- Pause the low-priority work,
- Switch to the urgent lane,
- Resume the paused work later.
Practical implication:
- While a heavy transition (like filtering a large list) is running,
- If the user types or clicks, React can jump to the higher-priority lane, keeping the UI responsive.
11.5 Multiple Updates and Lane Merging
Because lanes are bitmasks, React can easily combine updates:
- Two default-priority
setStatecalls → same lane. - One default and one transition update → different lanes, but both live in
pendingLanes.
Example:
root.pendingLanes = SyncLane | TransitionLane;When React picks what to work on:
- It first picks
SyncLane(urgent work). - Once that’s done, if
TransitionLanestill has pending work, it processes that next.
This is how React supports multiple “streams” of work at different priorities.
11.6 Lanes vs. Old Expiration Times
Older concurrent React prototypes used expiration times (timestamps) to represent priority.
Lanes replaced that model because:
- Bitmasks are faster to manipulate.
- Priority categories are explicit (sync, default, transition, idle…).
- Grouping or combining lanes is straightforward.
- It’s easier to reason about “pending work sets” per root.
11.7 Why Lanes Matter to You (Practically)
As a React developer, you don’t assign lanes manually, but understanding them explains:
-
Why some updates feel more responsive than others
- User input vs deferred transitions vs idle work.
-
Why
startTransitionsmooths your UI- It moves work to lower-priority lanes so urgent lanes can run first.
-
Why renders may be restarted in concurrent mode
- Lower-priority lanes can be paused or thrown away in favor of more urgent lanes.
-
Why you might see effects run more than once
- Work may be re-rendered when a lane is interrupted and restarted, even though commits only happen when a lane finishes.
Rule of thumb:
- Use normal state updates (
setState,useState) for user-driven changes that should feel instant. - Wrap expensive, non-urgent UI updates in
startTransitionso React can schedule them in lower-priority lanes. - Treat background/non-visual updates as work that could be in the lowest lanes.
Lanes are the missing piece that connects fibers (data structure) with concurrent rendering (behavior).
12. Concurrent Rendering: Why Fibers (and Lanes) Matter
With React 18’s concurrent features, the Fiber architecture + lanes really shine.
Key points:
- The render phase can be interrupted:
- If a more urgent update appears (e.g., a keypress), React can pause low-priority work.
- React can prepare a new UI tree in the background without blocking the main thread as much.
- Only when ready does React go into the commit phase and update the DOM.
APIs like startTransition:
import { startTransition } from "react";
function Search() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
function handleChange(event) {
const value = event.target.value;
setQuery(value); // urgent lane
startTransition(() => {
setResults(expensiveFilter(value)); // transition lane
});
}
// ...
}This tells React:
setQueryis an urgent update (keep the input responsive).setResultscan be deferred, paused, or dropped+retried if the user keeps typing.
Fibers + lanes + concurrent rendering = React can coordinate this efficiently.
13. Putting It All Together
Let’s recap React’s rendering and reconciliation pipeline with lanes included:
- You trigger an update (
setState, new props, context change…). - React assigns the update a lane based on its priority:
- Urgent input, normal, transition, idle, etc.
- React enqueues an update on the appropriate Fiber and marks that lane as pending on the root.
- In the render phase (reconciliation):
- React picks the highest-priority pending lanes.
- It builds/updates the work-in-progress Fiber tree.
- It walks the tree depth-first, re-running components as needed.
- It compares new elements to previous ones and decides what to keep, move, add, or delete.
- It sets effect flags on Fibers (Placement, Update, Deletion, etc.).
- In concurrent mode, this phase can be interrupted, paused, or restarted when higher-priority lanes appear.
- Once the work-in-progress tree for the chosen lanes is ready, React enters the commit phase:
- Applies DOM mutations based on the flags.
- Runs layout effects and sets refs.
- Schedules passive effects (
useEffect) to run after paint. - Swaps the roles: work-in-progress tree becomes the new current tree.
- The browser paints the updated UI and React runs passive effects.
With that, you now have:
- A mental model of fibers, double trees, and reconciliation.
- An understanding of render vs commit and how effects fit in.
- Clarity on how lists and keys impact diffing.
- A picture of bailouts and how React skips unnecessary work.
- And finally, an internal model of lanes and priority, which is the core of concurrent rendering.
Loading comments...