How does React transform this code into a visible, interactive interface on the screen? What really happens behind the scenes?
This article provides a deep dive into React's complete rendering pipeline, step by step. We'll use the mental model of a simplified React implementation (like a "mini-react") to understand its core mechanics.
1. The Render Pipeline at a Glance 🏭
Think of React's render pipeline as a factory assembly line:
Raw Materials: Your JSX code.
Initial Processing: It's converted into a Virtual DOM.
Assembly Structure: A Fiber Tree is constructed.
Quality Control: The Diffing Algorithm finds what changed.
Final Product: The real DOM is updated.
The entire flow looks like this:
JSX → Virtual DOM → Fiber Tree → Reconciliation (Diffing) → DOM Commit
Let's break down each stage.
2. Step One: From JSX to Virtual DOM 📄
2.1 What is the Virtual DOM?
The Virtual DOM (VDOM) is a programming concept where a representation of a UI is kept in memory and synced with the "real" DOM. In React, it's a lightweight tree of JavaScript objects that describes what the UI should look like. It's the "blueprint" for the real DOM.
Why do we need a VDOM?
Directly manipulating the real DOM is slow:
Modifying an element forces the browser to recalculate styles, layout, and repaint.
Multiple modifications can trigger this expensive process multiple times.
This leads to performance bottlenecks and a sluggish UI.
The VDOM acts as a middleman:
- It's fast to create and modify JavaScript objects in memory.
- React can compare the new blueprint with the old one to find the minimal set of changes.
- These changes are then "batched" and applied to the real DOM in one go.
2.2 The JSX Transformation
When you write JSX:
<div className="container">
<h1>Hello</h1>
<p>World</p>
</div>
A compiler like Babel transforms it into React.createElement() calls:
// The core logic of createElement
const createElement = (type, props = {}, ...children) => {
// Children that are simple strings or numbers become text nodes
const processedChildren = children.map((child) =>
typeof child === 'object' ? child : createTextElement(String(child))
);
return {
type, // 'div'
props: {
...props, // { className: 'container' }
children: processedChildren,
},
};
};
// A special function to create text elements
const createTextElement = (text) => ({
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: [],
},
});
This produces a VDOM object tree:
{
type: 'div',
props: {
className: 'container',
children: [
{ type: 'h1', props: { children: [{ type: 'TEXT_ELEMENT', props: { nodeValue: 'Hello' } }] } },
{ type: 'p', props: { children: [{ type: 'TEXT_ELEMENT', props: { nodeValue: 'World' } }] } }
]
}
}
2.3 Handling Function Components
When the type is a function (a function component), React calls that function during the render phase:
function Welcome({ name }) {
return <h1>Hello, {name}!</h1>;
}
<Welcome name="React" />
The process is:
createElementcreates a VDOM node wheretypeis theWelcomefunction itself.During rendering, React sees that the
typeis a function and calls it:Welcome({ name: 'React' }).The return value of the function (another VDOM node,
<h1>...</h1>) is then processed.
3. Step Two: Building the Fiber Tree 🌳
3.1 What is Fiber?
Introduced in React 16, Fiber is the new reconciliation engine. A Fiber node is an enhanced VDOM node. Each Fiber corresponds to a component or element and contains more information, turning the VDOM tree into a linked list of work units.
A Fiber Node's Structure:
interface FiberNode {
type: string | Function; // The component or element type
props: object; // Its props
dom: Element | null; // A pointer to the real DOM node
child: FiberNode | null; // Pointer to the first child Fiber
sibling: FiberNode | null; // Pointer to the next sibling Fiber
return: FiberNode | null; // Pointer to the parent Fiber (the 'return' keyword is reserved)
alternate: FiberNode | null; // A link to the old Fiber from the previous render
effectTag: string; // Describes the work to be done (e.g., 'UPDATE', 'PLACEMENT')
hooks?: any[]; // State storage for Hooks
}
Why was Fiber necessary?
The old reconciler (in React 15 and earlier) was synchronous and uninterruptible. If the component tree was large, the main thread would be blocked for a long time, freezing the UI and making user interactions feel laggy.
Fiber's advantages:
- It breaks rendering work into small, manageable chunks.
- It can
pause,resume, andabortwork. - It supports scheduling work based on priority.
- It performs work during the browser's idle periods.
3.2 Building the Fiber Tree
The Fiber tree is built using a depth-first traversal:
// A function that processes one unit of work (one Fiber)
const performUnitOfWork = (fiber) => {
// 1. For function components, call the function to get its children
if (typeof fiber.type === 'function') {
reconcileChildren(fiber, [fiber.type(fiber.props)]);
}
// 2. For host components (like 'div'), create the DOM node
else if (typeof fiber.type === 'string') {
if (!fiber.dom) {
fiber.dom = createDOM(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
// 3. Return the next unit of work
if (fiber.child) {
return fiber.child; // First, go deep
}
// 4. If no child, look for a sibling, then go up to the parent
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling; // Then, go sideways
}
nextFiber = nextFiber.return; // Finally, go up
}
return null; // All done
};
The traversal order for a <div><h1></h1><p></p></div> tree would be: div → h1 → p → (back up to) div. This linked list structure allows React to pause at any point and resume later.
3.3 Time Slicing
React uses requestIdleCallback to schedule work during browser idle time:
// The main work loop
const workLoop = (deadline) => {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
// If all work is done, commit the changes to the DOM
if (!nextUnitOfWork && workInProgressRoot) {
commitRoot();
}
// Request the next idle period to continue working
requestIdleCallback(workLoop);
};
// Start the loop
requestIdleCallback(workLoop);
How Time Slicing Works:
The browser tells React, "You have 5ms of free time."
React works on as many Fiber nodes as it can within those 5ms.
Time's up! React yields control back to the browser, which can now handle user input.
When the browser is idle again, React resumes its work.
This ensures that even a massive render job won't block the main thread, keeping the UI responsive.
4. Step Three: Reconciliation and the Diffing Algorithm 🔍
Reconciliation is the process React uses to compare two VDOM trees to find the differences. This process is often called the "diffing algorithm."
The Goal of Reconciliation:
Find which nodes need to be updated.
Find which nodes need to be deleted.
Find which nodes need to be added.
Reuse existing DOM nodes as much as possible.
4.2 The Diffing Heuristics
React's diffing algorithm follows a few simple rules for performance:
It only diffs nodes at the same level. It won't try to match a
<div>at level 2 with a<div>at level 4.keyprops optimize lists. A stablekeyhelps React identify if a node was moved, added, or deleted.Different types produce different trees. If an element's type changes (e.g., from
<div>to<span>), React tears down the old tree and builds a new one from scratch.
Core Reconciliation Logic:
const reconcileChildren = (wipFiber, newChildren) => {
let oldFiber = wipFiber.alternate?.child; // The fiber from the previous render
for (let i = 0; i < newChildren.length || oldFiber; i++) {
const newElement = newChildren[i];
const isSameType = oldFiber && newElement && oldFiber.type === newElement.type;
let newFiber = null;
// Case 1: Same type, update props
if (isSameType) {
newFiber = {
type: oldFiber.type,
dom: oldFiber.dom, // Reuse the DOM node
alternate: oldFiber,
props: newElement.props,
return: wipFiber,
effectTag: 'UPDATE', // Mark for update
};
}
// Case 2: Different type, create new node
if (!isSameType && newElement) {
newFiber = {
type: newElement.type,
dom: null, // A new DOM node needs to be created
props: newElement.props,
return: wipFiber,
effectTag: 'PLACEMENT', // Mark for placement
};
}
// Case 3: Old node is gone, delete it
if (!isSameType && oldFiber) {
oldFiber.effectTag = 'DELETION';
deletions.push(oldFiber); // Add to a deletions list
}
// ... link fibers together (child/sibling) and advance oldFiber ...
}
};
By comparing newChildren with oldFiber, React efficiently determines the fate of each node.
5. Step Four: The Commit Phase 🚀
This is the grand finale. The Commit Phase is where React applies all the calculated changes to the real DOM. This phase is synchronous and uninterruptible.
Why can't the Commit Phase be interrupted?
If it were, the user might see a half-updated, broken UI. To ensure a consistent view, all DOM mutations must happen in a single, atomic operation.
5.2 Three Types of Commit Operations
The commit phase iterates through the list of Fibers with effectTags and performs three main operations:
**PLACEMENT**: Add a new node to the DOM.**UPDATE**: Update the attributes or content of an existing node.**DELETION**: Remove a node from the DOM.
Simplified Commit Logic:
const commitRoot = () => {
// 1. First, perform all deletions
deletions.forEach(commitWork);
// 2. Then, perform all placements and updates
commitWork(workInProgressRoot.child);
// 3. Save the current tree for the next render
currentRoot = workInProgressRoot;
workInProgressRoot = null;
};
const commitWork = (fiber) => {
if (!fiber) return;
// ... find the nearest parent with a DOM node ...
if (fiber.effectTag === 'PLACEMENT' && fiber.dom) {
parentDOM.appendChild(fiber.dom);
} else if (fiber.effectTag === 'UPDATE' && fiber.dom) {
updateDOM(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === 'DELETION') {
parentDOM.removeChild(fiber.dom);
}
// Recursively commit children and siblings
commitWork(fiber.child);
commitWork(fiber.sibling);
};
6. How Hooks Work 🎣
6.1 useState Under the Hood
How does useState "remember" its value between renders? It stores the state on the component's Fiber node.
let wipFiber = null;
let hookIndex = 0;
function useState(initialState) {
// Get the old hook from the previous render, if it exists
const oldHook = wipFiber.alternate?.hooks?.[hookIndex];
// Initialize the hook with the old state or initial state
const hook = {
state: oldHook ? oldHook.state : initialState,
queue: [], // A queue of state updates
};
// Process any pending updates in the queue
const actions = oldHook ? oldHook.queue : [];
actions.forEach(action => {
hook.state = typeof action === 'function' ? action(hook.state) : action;
});
// The setState function
const setState = (action) => {
hook.queue.push(action);
// Trigger a new render
wipRoot = { ... };
nextUnitOfWork = wipRoot;
deletions = [];
};
// Add the hook to the fiber and increment the index
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
6.2 The Rules of Hooks
Crucially, Hooks must be called in the same order on every render.
React doesn't identify hooks by name; it identifies them by their call order. hookIndex is reset to 0 at the beginning of each component render.
// ❌ WRONG: The call order changes
function Component({ condition }) {
if (condition) {
useState(0); // Sometimes hook #0
}
useState(''); // Sometimes hook #0, sometimes hook #1
}
// ✅ CORRECT: The call order is always the same
function Component({ condition }) {
const [count, setCount] = useState(0); // Always hook #0
const [name, setName] = useState(''); // Always hook #1
}
This is why you can't put Hooks inside conditions, loops, or nested functions.
7. A Full Example: Initial Render vs. Update
7.1 Initial Render
React.render(<Counter />, document.getElementById('root'));
Render Phase: React builds a Fiber tree for
Counter,div,h1, andbutton, creating DOM nodes for each in memory. Each gets aPLACEMENTeffect tag.Commit Phase: React appends the
div,h1, andbuttonto the real DOM.
7.2 Update Render
The user clicks the button, calling setCount(1).
Trigger Update:
setStatequeues an update and schedules a new render.Render Phase (Reconciliation):
React compares the new VDOM with the old one.
It finds the
h1's text content changed from "Count: 0" to "Count: 1".The
h1's Fiber node gets anUPDATEeffect tag. Other nodes have no changes.
- Commit Phase:
React sees the
UPDATEtag on the h1 Fiber.It performs a single DOM operation:
h1.textContent = 'Count: 1'.The
divandbuttonare untouched.
8. Conclusion
React's rendering pipeline is a masterclass in engineering that balances performance, developer experience, and maintainability.
Key Takeaways:
✅ Virtual DOM: A lightweight in-memory representation for efficient diffing.
✅ Fiber Architecture: Enables interruptible rendering, time slicing, and prioritization.
✅ Reconciliation (Diffing): An efficient algorithm to find minimal DOM changes.
✅ Hooks: Leverage the Fiber architecture to attach state to function components.
Understanding these mechanics empowers you to:
🎯 Write better, more performant code.
🐛 Debug performance issues effectively.
🚀 Master the broader React ecosystem.
By demystifying this "magic," we become more capable and confident React developers.
Over to You
What part of React's render pipeline surprised you the most? Or do you have a favorite performance optimization trick related to rendering? Share your thoughts in the comments below! 👇
References
Top comments (0)