Under the hood: React
Source: Dev.to
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, it’s 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 execution priority.
“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, blocking the main thread until they completed, which caused unresponsive UIs and janky animations.
scheduler solves this by implementing cooperative scheduling: tasks voluntarily yield control back to the host so that the main thread can be used for other work. A task runs for a short interval (≈ 5 ms), checks if it should yield, yields if the deadline has passed, and then waits for its next turn.
Cooperative scheduling is managed by the Message Loop, while the actual work is handled by the Work Loop.
Tasks, two loops, and two queues
The unstable_scheduleCallback function (hereafter scheduleCallback) is the primary way to schedule concurrent (non‑sync) work.
function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: Callback,
options?: { delay: number },
): Task {
// implementation...
}
Task object
export opaque type Task = {
id: number,
callback: Callback | null,
priorityLevel: PriorityLevel,
startTime: number,
expirationTime: number,
sortIndex: number,
isQueued?: boolean,
};
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;
scheduler uses these levels to manage execution priority.
Queues
scheduler maintains two priority queues (implemented with a min‑heap):
| Queue | Purpose |
|---|---|
| Task queue | Stores tasks ready to execute. |
| Timer queue | Stores tasks that aren’t yet eligible (their startTime is in the future). |
When a task is created, it receives a startTime (when it becomes eligible) and an expirationTime (the latest time it must run).
- If
startTime > currentTime, the task goes to the Timer queue. - Otherwise, it goes directly to the Task queue.
Calculating startTime
// Immediate tasks
startTime = currentTime; // available now
// Delayed tasks
startTime = currentTime + delay; // available in the future
Calculating expirationTime
expirationTime = startTime + timeout;
The timeout depends on the PriorityLevel and ranges from -1 ms for ImmediatePriority to roughly 10 000 ms for LowPriority.
sortIndex
- In the Timer queue,
sortIndexequalsstartTime. - In the Task queue,
sortIndexequalsexpirationTime.
Priority‑level examples
| Priority Level | Typical Triggers |
|---|---|
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 bypasses the scheduler 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()) {
// Task hasn't expired and we've reached the deadline.
break;
}
}
// ... execute callback ...
currentTask = peek(taskQueue);
}
}
The Work Loop dequeues tasks from the Task queue and executes their callbacks until it must yield to the host. It also promotes tasks from the Timer queue to the Task queue via advanceTimers. If a task runs longer than its allotted slice, its callback is replaced with a continuation callback so the scheduler can resume it later. Cancelled tasks are discarded when dequeued.
The Message Loop
When the Work Loop yields, React needs a mechanism to restart it. This is handled by the Message Loop. React prefers the non‑standard setImmediate API; if unavailable, it falls back to MessageChannel or setTimeout (for non‑browser environments).
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
const schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
performWorkUntilDeadline starts the Work Loop. When the Work Loop decides to yield, it calls schedulePerformWorkUntilDeadline(), which posts a message that triggers performWorkUntilDeadline on the next tick. Using MessageChannel avoids the 4 ms minimum delay imposed by setTimeout, allowing React to schedule work more precisely.