DEV Community

Hodeem
Hodeem

Posted on

Under the hood: React

Introduction

I've wanted to do this since the moment I started using React: understand what makes it tick. This is not a granular review of the source code. Instead, this is an overview of some of the key packages and integrations inside React. Doing this research helped me reason about React better and debug issues more confidently. Hopefully you'll gain a better perspective too.

The Layers

React, I discovered, isn't very useful as a standalone package. It functions as an API layer and delegates heavily to the react-reconciler package. That's why it's often paired with a renderer such as react-dom which is bundled with react-reconciler.

So, to learn about React, we have to learn about react-reconciler and its key dependency, the scheduler package. Let's start with scheduler.

scheduler

The scheduler package enables React's concurrent features by breaking work into small chunks and managing the execution priority. Why is this important?

"With synchronous rendering, once an update starts rendering, nothing can interrupt it until the user can see the result on screen."

  • React v18.0 | March 29, 2022 by The React Team

Before concurrent React, updates were synchronous, which means they blocked the main thread until they were complete. This resulted in unresponsive UIs and janky animations.

scheduler solves this by implementing "cooperative scheduling". In other words, tasks voluntarily yield control back to the host so that it can use the main thread for other tasks. This means scheduler does work for a short interval (~5ms), checks if it's time to yield, voluntarily yields control if the deadline has passed, and waits until it gets another turn to pick back up where it left off.

Cooperative scheduling is managed by the "Message Loop" and the actual work is managed by the "Work Loop".

Tasks, two loops, and two queues

The unstable_scheduleCallback function, I'll call it scheduleCallback to keep it simple, is the main means of scheduling concurrent (non-sync) work.

function unstable_scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: Callback,
  options?: {delay: number},
): Task {
Enter fullscreen mode Exit fullscreen mode

Work in the scheduler is stored in a Task object:

export opaque type Task = {
  id: number,
  callback: Callback | null,
  priorityLevel: PriorityLevel,
  startTime: number,
  expirationTime: number,
  sortIndex: number,
  isQueued?: boolean,
};
Enter fullscreen mode Exit fullscreen mode

The callback is the actual work to be done. It can be cancelled by setting this field to null. The priorityLevel is one of the scheduler's priority levels.

export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;

export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;
Enter fullscreen mode Exit fullscreen mode

scheduler uses these priority levels to manage execution priority.

To understand the next three fields startTime, expirationTime and sortIndex we need to first talk about the two queues in the scheduler - the Task queue and the Timer queue. These are both priority queues, implemented by using a min heap. In other words, in these priority queues the smallest values are at the front (have the highest priority) and are dequeued first.

The Task queue is used to store tasks that are ready to execute, and the Timer queue is used to store those that aren't. When tasks are created, they are assigned a startTime which indicates when they are eligible for execution and an expirationTime which indicates when they must be executed by. If the startTime is greater than currentTime, then the task is added to the Timer queue, or else it's added to the Task queue. The Task is pushed to the relevant queue in the scheduleCallback function.
Here's how the startTime is calculated:

Immediate tasks: startTime = currentTime // available now
Delayed tasks: startTime = currentTime + delay // available in the future
Enter fullscreen mode Exit fullscreen mode

The expirationTime creates a grace period that controls how long the scheduler is willing to yield before a Task becomes so urgent that it must be executed without interruption. To calculate the expiration time, we add a timeout to the startTime, and the value of the timeout depends on the PriorityLevel. The timeout value ranges from -1ms for ImmediatePriority to approx. 10,000ms for LowPriority. So,

expirationTime = startTime + timeout
Enter fullscreen mode Exit fullscreen mode

Tasks are sorted by the startTime in the Timer queue, and by the expirationTime in the Task queue. Therefore, the sortIndex is equal to the startTime for tasks in the Timer queue, and the expirationTime for those in the Task queue.

Here are some examples of triggers for different priority levels:

Priority Level Trigger examples
ImmediatePriority (Sync) Rarely used in modern React
UserBlockingPriority onScroll, onDrag, onMouseMove
NormalPriority startTransition(), useDeferredValue (default for most scheduler work)
IdlePriority Offscreen/hidden content rendering

Synchronous work skips the scheduling and is queued using the queueMicrotask API.

The Work Loop

function workLoop(initialTime: number) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (currentTask !== null) {
    if (!enableAlwaysYieldScheduler) {
      if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
        // This currentTask hasn't expired, and we've reached the deadline.
        break;
      }
    }
    // ... execute callback ...
  }
}
Enter fullscreen mode Exit fullscreen mode

The Work Loop's primary function is to dequeue Tasks from the Task queue and execute their callbacks until it's time to yield control to the host. The Work Loop also updates the two queues via advanceTimers. Tasks in the Timer queue whose startTime has passed (startTime <= currentTime) are promoted to the Task queue.

Long-running or incomplete Tasks' existing callback is replaced with a "continuation callback", which allows the scheduler to pick up where it left off on the next run. Cancelled tasks are simply discarded when dequeued. The loop repeatedly checks shouldYieldToHost to decide if it should continue working or stop.

The Message Loop

You may be wondering, "So when the Work Loop exits, what mechanism restarts it?". That's where the Message Loop comes in. React prefers to use the non-standard setImmediate API, but if the environment doesn't support it then it falls back to the Channel Messaging API or setTimeout for non-browser environments.

This is one of the clever parts of React - a bit cheeky if you ask me. The Channel Messaging API was designed for cross-context communication, but React keeps both ports and uses them for a scheduling trick. Here's how:

  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
Enter fullscreen mode Exit fullscreen mode

performWorkUntilDeadline is the function that ultimately starts the Work Loop and when the Work Loop is ready to yield to the host, another message is posted by invoking schedulePerformWorkUntilDeadline().

This ensures that when the message is processed by the host, the onmessage callback fires and the Work Loop picks up where it left off. Why this API? By using the Channel Messaging API, React avoids the 4ms minimum delay associated with an alternative like setTimeout. Tecnologia!

react-reconciler

The react-reconciler package determines what changes need to be made to the UI and leverages scheduler to get the work done. It is based on the Fiber Architecture.

The Fiber Architecture

A Fiber is a JavaScript object representing a unit of work - it contains everything React needs to process that component. Here's a simplified Fiber type:

type Fiber = {
  // Identity
  tag: WorkTag,              // Component type (Function, Class, Host, etc.)
  type: any,                 // The actual component (function, class, 'div')
  key: string | null,        // For reconciliation

  // Tree structure
  return: Fiber | null,      // Parent
  child: Fiber | null,       // First child
  sibling: Fiber | null,     // Next sibling

  // Effects
  flags: Flags,              // What work this fiber needs (Placement, Update, etc.)
  subtreeFlags: Flags,       // What work children need (bubbled up)

  // Scheduling
  lanes: Lanes,              // Priority of pending work
  childLanes: Lanes,         // Priority of children's work

  // Double buffering
  alternate: Fiber | null,   // The other version of this fiber
};
Enter fullscreen mode Exit fullscreen mode

Flags mark what work needs to be done on a fiber during commit. They're bitmasks combined with | and checked with &.

// Common flags
export const NoFlags =        0b0000000000000000000000000000000;
export const Placement =      0b0000000000000000000000000000010;  // Needs DOM insertion
export const Update =         0b0000000000000000000000000000100;  // Needs DOM update
export const ChildDeletion =  0b0000000000000000000000000010000;  // Has children to remove
export const Ref =            0b0000000000000000000001000000000;  // Ref needs attaching
export const Passive =        0b0000000000000000000100000000000;  // Has useEffect
export const Snapshot =       0b0000000000000000000010000000000;  // getSnapshotBeforeUpdate
export const Callback =       0b0000000000000000000000001000000;  // Has setState callback
Enter fullscreen mode Exit fullscreen mode

Here's an example of how flags are used to determine if there's work to be done:

if ((flags & Update) !== NoFlags) {
  // This fiber has an Update effect
  const updateQueue = finishedWork.updateQueue;
  // ... run update effects
}
Enter fullscreen mode Exit fullscreen mode

Lanes represent priority levels for updates. Higher priority means lower bit position. Each update gets assigned a lane and React processes higher-priority lanes first.

// Priority order (highest to lowest)
export const SyncLane =              0b0000000000000000000000000000010;  // Urgent (click, input)
export const InputContinuousLane =   0b0000000000000000000000000001000;  // Drag, scroll
export const DefaultLane =           0b0000000000000000000000000100000;  // Normal updates
Enter fullscreen mode Exit fullscreen mode

These lanes are mapped to priorities from the scheduler before calling the scheduleCallback function.

   let schedulerPriorityLevel;
    switch (lanesToEventPriority(nextLanes)) {
      // Scheduler does have an "ImmediatePriority", but now that we use
      // microtasks for sync work we no longer use that. Any sync work that
      // reaches this path is meant to be time sliced.
      case DiscreteEventPriority:
      case ContinuousEventPriority:
        schedulerPriorityLevel = UserBlockingSchedulerPriority;
        break;
      case DefaultEventPriority:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
      case IdleEventPriority:
        schedulerPriorityLevel = IdleSchedulerPriority;
        break;
      default:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
    }

    const newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performWorkOnRootViaSchedulerTask.bind(null, root),
    );
Enter fullscreen mode Exit fullscreen mode

Here's an example of how lanes are used to check if a fiber has pending work:

export function includesSomeLane(a: Lanes, b: Lanes): boolean {
  return (a & b) !== NoLanes;  // ← BITMASK AND
}

...

function checkScheduledUpdateOrContext(current: Fiber, renderLanes: Lanes): boolean {
  const updateLanes = current.lanes;
  if (includesSomeLane(updateLanes, renderLanes)) {
    return true;  // Has work in these lanes
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Fibers are stored in two trees, the Current tree and the Work-in-Progress (WIP) tree. The Current tree represents the UI that's currently visible to the user, while the WIP tree represents the next state of the UI that's being built.

Each fiber has an alternate property that links corresponding fibers between trees. After all the changes have been committed, the trees swap roles. So, the WIP tree becomes Current and the Current becomes the new WIP. This "double buffering" pattern allows React to quickly swap to the new UI atomically and build the next UI without affecting the current one.

Reconciliation

The reconciliation in react-reconciler is split into two phases: the render phase and the commit phase.

The render phase

The render phase is where React traverses the fiber tree, calls your component functions, and determines what changed. This phase is interruptible, meaning that React can pause work to let the host handle user input, then resume later.

For concurrent updates react-reconciler schedules work using scheduler's scheduleCallback function that was shown earlier.

When this callback is eventually processed by the scheduler's work loop, it indirectly starts another work loop in the react-reconciler. This second loop in the react-reconciler traverses the fiber tree and processes each fiber.

The function responsible for this second work loop is workLoopConcurrentByScheduler.

function workLoopConcurrentByScheduler() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}
Enter fullscreen mode Exit fullscreen mode

This second work loop processes one fiber at a time. After each unit of work, the loop checks if it's time to yield or if there's no more work. If either condition is true, React yields back to the host and the scheduler can resume later. Note: shouldYield is the shouldYieldToHost function exported from the scheduler.

If this is feeling a bit loopy, don't worry - it took me a while to get a hang of it. To summarize:

  • The scheduler's Work Loop mainly processes Tasks and is interruptible between Tasks.
  • The scheduler's Message Loop exists to ensure that the Work Loop is re-entered if there are outstanding Tasks.
  • The react-reconciler's Work Loop traverses the Fiber tree, processes Fibers and is interruptible between Fibers.
  • Multiple Fibers can be processed within a single Task.

performUnitOfWork is the core of rendering. Each fiber passed to it goes through two functions, beginWork and completeWork:

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;

  // Phase 1: "Begin" - process this fiber, return its first child
  let next = beginWork(current, unitOfWork, entangledRenderLanes);

  // Other code

  if (next === null) {
    // No children - complete this fiber and move to sibling/parent
    completeUnitOfWork(unitOfWork);
  } else {
    // Has children - process the first child next
    workInProgress = next;
  }
}
Enter fullscreen mode Exit fullscreen mode

Traversal of the fiber-tree is depth-first, and beginWork is called for each fiber as we descend. beginWork does the following:

  • Checks if the fiber has pending work in the current lanes (fiber.lanes)
  • Calls your component function (for function components) or render() (for class components)
  • Reconciles children by diffing old vs new to decide what changed
  • Sets flags on fibers that need DOM work
  • Returns the first child fiber (or null if no children)

The completeUnitOfWork function is called when we're going back up.
When a fiber has no children (or all children are processed), the react-reconciler "completes" it. This means that the function:

  • Creates actual DOM nodes (for host components like <div>)
  • Bubbles child flags up to subtreeFlags so the commit phase knows which subtrees have work.
function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    // Create/update DOM nodes, bubble up flags
    completeWork(current, completedWork, entangledRenderLanes);

    // Move to sibling, or back up to parent
    if (siblingFiber !== null) {
      workInProgress = siblingFiber;
      return;
    }
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
}
Enter fullscreen mode Exit fullscreen mode

Here's an example of how the react-reconciler walks the tree depth-first:

     App
    /   \
  Header  Main
  /         \
Logo      Article

Traversal order:
1. beginWork(App)       returns Header
2. beginWork(Header)    returns Logo  
3. beginWork(Logo)      returns null
4. completeWork(Logo)   move to sibling (none), go up
5. completeWork(Header) move to sibling Main
6. beginWork(Main)      returns Article
7. beginWork(Article)   returns null
8. completeWork(Article)
9. completeWork(Main)
10. completeWork(App)   done!
Enter fullscreen mode Exit fullscreen mode

Not every fiber needs processing on every render. The entangledRenderLanes variable tracks which priority levels we're currently processing. In beginWork, React checks:

if ((current.lanes & renderLanes) === NoLanes) {
  // This fiber has no pending work at the current priority
  // Bail out - reuse the existing fiber
  return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
Enter fullscreen mode Exit fullscreen mode

This lets React skip entire subtrees that don't have updates at the current priority.

When the work loop finishes (all fibers processed), the WIP tree is complete and then the react-reconciler moves to the commit phase to apply these changes to the actual DOM.

The commit phase

The commit phase is actually divided into 3 synchronous sub-phases. They are the Before Mutation, Mutation, and Layout phases.

The Before Mutation phase captures any DOM state that might change (like scroll positions) before React modifies anything. Note: This is when getSnapshotBeforeUpdate runs.

The "Mutation" phase is where the DOM is actually modified. Operations such as inserting new nodes, updating attributes/text, and removing deleted nodes take place in this phase. The react-reconciler processes all fibers with Placement, Update, or ChildDeletion flags. This is the phase when the user-visible changes happen.

The tree swap (when the WIP tree becomes the new Current tree) takes place between the Mutation and Layout phases.

The Layout phase serves to run effects that need to read the freshly-updated DOM synchronously. This includes running the useLayoutEffect hooks, attaching refs, and class lifecycle methods like componentDidMount/componentDidUpdate. These run before the host paints, so you can measure the DOM or make synchronous adjustments.

There's also a fourth phase that happens asynchronously after paint:
the Passive Effects phase which runs useEffect callbacks. These are intentionally delayed so they don't block the host from painting the updated UI to screen.

HostConfig

By now you may have noticed that these methods and terms are a bit abstract. That's by design. Remember that the react-reconciler is primarily concerned with managing the Fiber trees and is flexible enough to be used in different environments. This is facilitated by a conceptual interface called HostConfig that allows different renderers to give react-reconciler instructions on how to manage the specific host platform.

I say conceptual because you won't find an interface called HostConfig in the source code; however, there are certain fields and methods that the react-reconciler expects to import. Here's a simplified snippet of the HostConfig 'interface':

const HostConfig = {
  createInstance(type, props) {
    // e.g. DOM renderer returns a DOM node
  },
  // ...
  supportsMutation: true, // it works by mutating nodes
  appendChild(parent, child) {
    // e.g. DOM renderer would call .appendChild() here
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

The react-reconciler expects to import these properties from the ReactFiberConfig file. For example,

import {
  createInstance,      
  createTextInstance,          
  resolveSingletonInstance,
// more imports
} from './ReactFiberConfig';
Enter fullscreen mode Exit fullscreen mode

However, if you check the contents of the ReactFiberConfig.js file, here's what you'll find:

throw new Error('This module must be shimmed by a specific renderer.');
Enter fullscreen mode Exit fullscreen mode

There are two classes of renderers, first-party renderers and third-party renderers and the way shimming is done depends on the class. First-party renderers are those found in the React monorepo, and third-party renderers are those that aren't.

For first-party renderers, the shim is the correct fork of the ReactFiberConfig file identified at build-time by the bundler. For example, if you're bundling the react-dom renderer, then the ReactFiberConfig.dom.js fork will be selected. The fork has the implementation that satisfies the HostConfig 'interface'.

For third-party renderers, you'll have to pass your HostConfig object to the instantiated Reconciler. It'll look something like this:

const Reconciler = require('react-reconciler');

const HostConfig = {
  // You'll need to implement some methods here.
};

const MyRenderer = Reconciler(HostConfig);

const RendererPublicAPI = {
  render(element, container, callback) {
    // Call MyRenderer.updateContainer() to schedule changes on the roots.
    // See ReactDOM, React Native, or React ART for practical examples.
  }
};

module.exports = RendererPublicAPI;
Enter fullscreen mode Exit fullscreen mode

Hooks

Now, let's touch on hooks. If you've ever gone to the implementation of useState you may have seen something like this:

function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  return dispatcher;
}

export function useState(initialState) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}
Enter fullscreen mode Exit fullscreen mode

The first time I saw it, it was a real head-scratcher. One interesting thing that I discovered was that react and react-reconciler are linked primarily by a shared mutable object called ReactSharedInternals. react exports the blank object, which looks like this:

const ReactSharedInternals: SharedStateClient = ({
  H: null, // `H` is for hooks, that's all you need to know for now.
  A: null,
  T: null,
  S: null,
}: any);
if (enableGestureTransition) {
  ReactSharedInternals.G = null;
}
Enter fullscreen mode Exit fullscreen mode

and react-reconciler imports the object indirectly, writes to it, and react is able to read from the updated object.

It gets even more interesting. H will get assigned a Dispatcher, and the Dispatcher depends on the phase that React is currently in. For example, if it's in the mount phase then ReactSharedInternals.H = HooksDispatcherOnMount. Or if it's in the update phase then ReactSharedInternals.H = HooksDispatcherOnUpdate.

Each Dispatcher has phase-specific implementation for most of its properties:

const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  use,
  useCallback: mountCallback,
  ...
  useState: mountState,
  ...
}


const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,
  use,
  useCallback: updateCallback,
  ...
  useState: updateState,
  ...
}

export const ContextOnlyDispatcher: Dispatcher = {
  readContext,
  use,
  useCallback: throwInvalidHookError,
  ...
  useState: throwInvalidHookError,
  ...
}
Enter fullscreen mode Exit fullscreen mode

The initial setup takes place when you call root.render(<App />) in your entry file, and the fields of ReactSharedInternals are updated repeatedly at runtime. ContextOnlyDispatcher is assigned to ReactSharedInternals.H when React is outside of the render phase. So, if you've ever wondered where the 'Rule of Hooks' error was being thrown:

function throwInvalidHookError() {
  throw new Error(
    'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
      ' one of the following reasons:\n' +
      '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
      '2. You might be breaking the Rules of Hooks\n' +
      '3. You might have more than one copy of React in the same app\n' +
      'See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.',
  );
}
Enter fullscreen mode Exit fullscreen mode

Now you know.

Conclusion

There are many more aspects of React that couldn't be covered in one article. Who knows? maybe I'll write about them someday. If you learned something new, please like and share this. And if you want me to do a deep dive on Hooks or some other React concept, leave a comment. Thanks for reading.

Resources

Top comments (0)