DEV Community

Cover image for React's Render Pipeline: A Deep Dive From JSX to the Screen
Ben
Ben

Posted on

React's Render Pipeline: A Deep Dive From JSX to the Screen

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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: [],
  },
});
Enter fullscreen mode Exit fullscreen mode

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' } }] } }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

The process is:

  1. createElement creates a VDOM node where type is the Welcome function itself.

  2. During rendering, React sees that the type is a function and calls it: Welcome({ name: 'React' }).

  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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, and abort work.
  • 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
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

How Time Slicing Works:

  1. The browser tells React, "You have 5ms of free time."

  2. React works on as many Fiber nodes as it can within those 5ms.

  3. Time's up! React yields control back to the browser, which can now handle user input.

  4. 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:

  1. 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.

  2. key props optimize lists. A stable key helps React identify if a node was moved, added, or deleted.

  3. 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 ...
  }
};

Enter fullscreen mode Exit fullscreen mode

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:

  1. **PLACEMENT**: Add a new node to the DOM.

  2. **UPDATE**: Update the attributes or content of an existing node.

  3. **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);
};
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode
  1. Render Phase: React builds a Fiber tree for Counter, div, h1, and button, creating DOM nodes for each in memory. Each gets a PLACEMENT effect tag.

  2. Commit Phase: React appends the div, h1, and button to the real DOM.

7.2 Update Render

The user clicks the button, calling setCount(1).

  1. Trigger Update: setState queues an update and schedules a new render.

  2. 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 an UPDATE effect tag. Other nodes have no changes.

  1. Commit Phase:
  • React sees the UPDATE tag on the h1 Fiber.

  • It performs a single DOM operation: h1.textContent = 'Count: 1'.

  • The div and button are 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

  1. React Docs - Preserving and Resetting State
  2. React Fiber Architecture by Andrew Clark
  3. Building a mini-React by Rodrigo Pombo

Top comments (0)