Skip to main content

How the work loop works

Before we dive into the work loop, let's see how it starts for the first time.

How the concurrent work loop starts

For the concurrent root, the last thing we did is to schedule in scheduleTaskForRootDuringMicrotask a callback to performConcurrentWorkOnRoot.

Let's break into the concurrent work on root.

root.callbackNode = scheduleCallback(
schedulerPriorityLevel,// NormalPriority for simple root.render
performConcurrentWorkOnRoot.bind(null, root),
);

performConcurrentWorkOnRoot signature

performConcurrentWorkOnRoot is defined as follows:

export function performConcurrentWorkOnRoot(
root: FiberRoot,
didTimeout: boolean,
): RenderTaskFn | null { /* [Not Native Code] */ }

The root property was bound when scheduling the callback, and the didTimeout will be passed when the scheduler will be executing this callback.

// inside scheduler, simplified
const didTimeout = didCallbackTimedOut();
performConcurrentWorkOnBoundRoot(didTimeout);

// where
const performConcurrentWorkOnBoundRoot = performConcurrentWork.bind(null, root);

This function will be rendering your components and performing the whole logic, so basically it will be insanely long, but we will move some parts from it to their own sections (like WorkTags, effects types and their implementation details, and obviouslyhooks too ).

Implementation details

TL;DR

if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
throw new Error('Should not already be working.');
}
const originalCallbackNode = root.callbackNode;
const didFlushPassiveEffects = flushPassiveEffects();

if (didFlushPassiveEffects && root.callbackNode !== originalCallbackNode) {
return null;
}
let lanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
if (lanes === NoLanes) {
return null;
}
const shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) &&
(disableSchedulerTimeoutInWorkLoop || !didTimeout);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
if (renderWasSuccessfull) {
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, finishedWork, lanes);
} else {
// manage errors and suspense
}
ensureRootIsScheduled(root);
return getContinuationForRoot(root, originalCallbackNode);
  1. Guard against calls to this function when already rendering or committing

    if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
    throw new Error('Should not already be working.');
    }
  2. Flush passive effects

    Effects will be fully discussed in their own section. It will be linked here once ready.

    // simplified

    // this referencement here because flushPassiveEffects might schedule updates
    const originalCallbackNode = root.callbackNode;
    const didFlushPassiveEffects = flushPassiveEffects();

    if (didFlushPassiveEffects && root.callbackNode !== originalCallbackNode) {
    return null;
    }

    When passive effects are flushed and something did cancel the current scheduled callback, this function will stop execution and return null, because the other schedule would trigger its own work.

    From the root.render() perspective, we have no effects until now, so we will continue the execution. That's another reason to skip this section for now.

  3. Compute again the nextLanes

    This is a leftover that should be fixed (until now), the next lanes are computed here again (we computed them first above in the scheduleTaskForRootDuringMicrotask function.)

    And obviously, if there are NoLanes (no work to do), then null is returned.

  4. Determine if it should time slice

    const shouldTimeSlice =
    !includesBlockingLane(root, lanes) &&
    !includesExpiredLane(root, lanes) &&
    (disableSchedulerTimeoutInWorkLoop || !didTimeout);

    To use time slicing , the root should not include BlockingLanes or ExpiredLanes.

    Blocking lanes are (per the previous link): SyncDefaultLanes, InputContinuousHydrationLane, InputContinuousLane, DefaultHydrationLane and DefaultLane.

    Yes, from root.render() without transition we are in DefaultLane so this render will be considered without time slicing, and thus a Sync one.

  5. Call render function based on time slicing

    Depending on whether it should time slice, it will call renderRootConcurrent or else it will fall back to renderRootSync.

    let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes);

    The whole rendering logic should be done after calling the previous functions.

    We will see them in details in a few, but keep in mind that after the whole render ends, we will perform the following steps too

  6. Finish rendering and perform logic based on exitStatus

    The possible exitStatus values are listed here. React will then either mark the root as suspended if the render did not complete, or else:

    1. Verify that the render is consistent with external stores. If not, a sync render will be performed

      Then the while(true) loop continue until a good exitStatus is obtained.

    2. In case of RootErrored errors, React will attempt to recover from them if possible. It will attempt to render again using renderRootSync.

    3. If the exitStatus is RootFatalErrored: TBD, this highlights probably a bug in React that prevented it from rendering.

    4. Or else, then the tree is consistent and the work is considered as finished

      // simplified

      // React when rendering (we will see in the next section) will store
      // the render work output in a property called alternate in each fiber
      // we will see them in details in a few
      const finishedWork: Fiber = root.current.alternate;

      This variable is then affected to the finishedWork property of the root and then finishConcurrentRender is called.

      root.finishedWork = finishedWork;
      root.finishedLanes = lanes;
      finishConcurrentRender(root, exitStatus, finishedWork, lanes);

      // todo: back to this once renderRoot is done

  7. Ensure root is scheduled

    We already saw this in the how root.render() works section which will schedule again a micro task to attempt to render the root again, but mostly it won't find any work to do (it will know by only looking at the nextLanes).

  8. Return a continuation for the root

    This section is not available yet. Please fill an issue.

    While waiting for the newsletter, you can get notified when new content drops by following me on X.

How renderRootSync works

So, we are finally there! The function that renders our components.

function renderRootSync(root: FiberRoot, lanes: Lanes) {
// [Not Native Code]
}

TL;DR

const prevExecutionContext = executionContext;
executionContext |= RenderContext;
const prevDispatcher = pushDispatcher(root.containerInfo);
if (workInProgressRoot !== null || workInProgressRootRenderLanes !== lanes) {
// [...] some work
prepareFreshStack(root, lanes);
}
do {
try {
if (didSuspendDuringHydration) {
resetWIPStack();
workInProgressRootExitStatus = RootDidNotComplete;
break;
}

// [...] Other branches to break when needed

workLoopSync();
// Why a break here you wonder ? Hint: there is no break in the catch block
break;
} catch (e) {
handleThrow(root, e);
}
} while (true)
if (didSuspendInShell) {
root.shellSuspendCounter++;
}

executionContext = prevExecutionContext;
workInProgressRoot = null;
workInProgressRootRenderLanes = NoLanes;
finishQueueingConcurrentUpdates();
return workInProgressRootExitStatus;

1. Mark render started

There is a global variable that WorkLoop module uses: executionContext, which is a number used the same way Lanes are used. The possible values are:

export const NoContext = /*             */ 0b000;
const BatchedContext = /* */ 0b001;
export const RenderContext = /* */ 0b010;
export const CommitContext = /* */ 0b100;

When the render starts, the executionContext is modified as follows:

const prevExecutionContext = executionContext;
executionContext |= RenderContext;

// later, after the render finishes:
executionContext = prevExecutionContext;

This variable is so important to React, it can know what kind of work it is performing and guard against mis-usages.

2. Push the context only dispatcher

The React dispatcher is an object having several properties (such as hooks) that is used by React to propagate behavior like in dependency injection fashioned way.

At this point when the work loop is about to start, this is a transitory phase and coming from the root.render path, a ContextOnlyDispatcher is used which will allow only readContext and use.

The same is done to the upcoming cache feature of React, it will have its own dispatcher.

The dispatchers will be more detailed in their own section.

3. Prepare a fresh stack

Next, renderRoot will have to check the currently processed root and its lanes from the global variables against the root that it received from arguments while scheduling.

You can see it here.

if (workInProgressRoot !== null || workInProgressRootRenderLanes !== lanes) {
// some work
prepareFreshStack(root, lanes);
}

Preparing a fresh stack is the process of removing and emptying any information that was attached during a previous work or render.

Let's dive into its steps:

  • Set the following root properties to null

    • finishedWork
    • finishedLanes
    • timeoutHandle, will also cancel any existing timeout (to commit)
    • cancelPendingCommit
  • Reset the work in progress stack

    React will do nothing here when coming from root.render(), but it will reset several Fiber modules and their global variables (their internal state) and interrupt any ongoing work. This is normal since we are starting a render cycle and things need to be clean. This process may be the most important and critical to be done correctly.

  • Reset the work loop internal state variables

    This involves setting the following variable to their default values:

    • workInProgressRoot = root
    • workInProgress = createWorkInProgress(root.current, null): Pay attention to this particular variable involving the creation of a second fiber.
    • renderLanes = lanes
    • workInProgressRootRenderLanes = lanes
    • workInProgressSuspendedReason = NotSuspended
    • workInProgressThrownValue = null
    • workInProgressRootDidAttachPingListener = false
    • workInProgressRootExitStatus = RootInProgress
    • workInProgressRootFatalError = null
    • workInProgressRootSkippedLanes = NoLanes
    • workInProgressRootInterleavedUpdatedLanes = NoLanes
    • workInProgressRootRenderPhaseUpdatedLanes = NoLanes
    • workInProgressRootPingedLanes = NoLanes
    • workInProgressRootConcurrentErrors = null
    • workInProgressRootRecoverableErrors = null

    The createWorkInProgress is very important, so we should visit it too. It is responsible for creating a second fiber (the alternate) that mirrors out FiberNode (root.current). We will be seeing it again during the work.

    The alternate is used by react as a DRAFT or a SNAPSHOT of the ongoing work (or render), that once finished will be committed to be the main fiber and then the previous main fiber is released.

    The createWorkInProgress will be revisited again in the next section.

  • Finish queuing the concurrent updates

    Do you remember the concurrentQueues variable from how root.render() works? Well, root.render() did not do anything to our root, but it just scheduled its render via microtask queue, and at the same time, it did leave the render children as an update stored in a global variable; like this:

    concurrentQueues[id++] = fiber;
    concurrentQueues[id++] = sharedQueue;
    concurrentQueues[id++] = update;
    concurrentQueues[id++] = lane;

    Now is time to process this array with the following steps:

    • Loop to the end of this array and each time reference the variables using the same order we added them, then remove them from the array by setting them to null.

      while (i < end) {
      const fiber: Fiber = concurrentQueues[i];
      concurrentQueues[i++] = null;
      const queue: ConcurrentQueue = concurrentQueues[i];
      concurrentQueues[i++] = null;
      const update: ConcurrentUpdate = concurrentQueues[i];
      concurrentQueues[i++] = null;
      const lane: Lane = concurrentQueues[i];
      concurrentQueues[i++] = null;

      // at this point the update is attached to fiber's queue, it wasn't before
      if (queue !== null && update !== null) {
      const pending = queue.pending;
      if (pending === null) {
      update.next = update;
      } else {
      update.next = pending.next;
      pending.next = update;
      }
      queue.pending = update;
      }
      if (lane !== NoLane) {
      // this will make use of the fiber, we've already seen it too in
      // the root.render section.
      // it will merge these lanes starting from this fiber to its root
      // (including alternate if present)
      markUpdateLaneFromFiberToRoot(fiber, update, lane);
      }
      }

4. Trigger the work loop

Finally, for real this time (almost)! we are there!

React will then start a do while(true) loop where it will call workLoopSync() (the work loop 🙄). Let's take a look at the simplified loop:

// simplified

do {
try {
if (didSuspendDuringHydration) {
resetWIPStack();
workInProgressRootExitStatus = RootDidNotComplete;
break;
}

workLoopSync();
// Why a break here you wonder ? Hint: there is no break in the catch block
break;
} catch (e) {
handleThrow(root, e);
}
} while (true)

The workLoopSync is defined as:

function workLoopSync() {
// read well the following comment from React codebase
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}


function workLoopConcurrent() {
// read well the following comment from React codebase
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}

Here is a simplified version of how performUnitOfWork works:

// simplified
// the unitOfWork Fiber passed here is the global workInProgress variable
// which was initialized by the fiberRoot.current.alternate

// so basically the workInProgress initially = the alternate
// which means that the 'current' tree is the workInProfress.alternate
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;

const next = beginWork(current, unitOfWork, renderLanes);

unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}

So begin work will render the fiber and return the next one.

beginWork is detailed in the next section, but it is important to note at this stage that it receives both the current unit of work and its alternate. it is like: render into the alternate the main fiber using the renderLanes and return the next unit of work.

Like mentioned earlier, the alternate refers to the workInProgerss while the original fiber will be the current tree.

This is the actual sync work loop: Keep performing work until there is nothing left to do.

As you may know already, React will construct a fiber tree from the received children to root.render and will keep working until it reaches the end. How they are linked and how the next fiber is chosen will be detailed in a separate section, this one is for the loop.

note

beginWork will end up calling the reconcileChildren function whose role to find child to work on next.

So, React will go down in your tree until it reaches the very bottom in sequential way: It will perform work on each component and its first child recursively until the bottom.

When the next child is null, then React will call completeWork on the work that has no children, and in this process, React will either work on its sibling (the component at the same level on the tree) or its parent's sibling (their uncle).

This process is detailed in the how completeWork works section.

5. Handle thrown error

If the render phase did throw an error , it is a sign that React will either show Suspense fallback, show an error boundary or a blank page.

This process is complex and long and manages many cases, we will dedicate a whole section for it.

This section is not available yet. Please fill an issue.

While waiting for the newsletter, you can get notified when new content drops by following me on X.

6. Restore execution context and dispatcher

So by here the render is completed, React will then proceed to reset some variables and perform some checks:

// workloop code

resetContextDependencies();
restoreExecutionContextAndDispatcher();
throwAsDefensiveIfThereIsStillSomeWorkToDo();
  • Reset context dependencies

    This will reset the context's module internal state (module variables) because render did complete.

  • Restore execution context

    // we've already seen it above
    executionContext = prevExecutionContext;
  • Throw if there is still work to do

    if (workInProgress !== null) {
    throw new Error(
    'Cannot commit an incomplete root. This error is likely caused by a ' +
    'bug in React. Please file an issue.',
    );
    }

7. Finish the updates

This is the same process we've seen when preparing a fresh stack, React will perform a second pass on the concurrentQueues in case another update is scheduled when rendering.

In a simple case, nothing will be done.

finishQueueingConcurrentUpdates();

How renderRootConcurrent works

There are noticeable differences from the sync render, to pass via this path from root.render(), you should satisfy the shouldTimeSlice condition.

To do so, the simplest way (if not the only way) is to wrap root.render() with startTransition.

In this case, renderRootConcurrent is called with the same signature. Let's dive into it:

todo: back to this after begin work and leaf sections