⚛ The Course  /  Module 3 · Priority (Lanes) · 3.3
Module 3 · Priority (Lanes)

3.3 How React picks the next work

Your component tree is full of updates that are all waiting to happen. A click queued one. A startTransition queued another. Each one carries a lane — a single priority bit. Before React can render a single fiber, it has to answer one question: of everything waiting, which slice do I work on right now? This lesson is the function that answers it.

Where the waiting work lives

Every pending lane is recorded in two places at once: on the individual fibers that have updates, and on the FiberRoot — the single object at the top of the tree. The root holds the union of every pending lane anywhere below it.

The real code

// Fiber node level
fiber.lanes        // Lanes for THIS fiber's pending updates
fiber.childLanes   // Lanes for any update anywhere in THIS fiber's subtree

// FiberRoot level
root.pendingLanes    // ALL pending lanes across the entire tree
root.expiredLanes    // Lanes that have passed their deadline (starvation prevention)
root.suspendedLanes  // Lanes blocked by Suspense
root.pingedLanes     // Lanes ready to retry after a Suspense promise resolved

root.pendingLanes is the gate agent's list of every ticket in the building. The other three fields are notes about who is eligible to board: suspendedLanes are passengers whose flight is held, pingedLanes are the ones just cleared to fly again, and expiredLanes are the ones who have waited too long.

One question: which lanes this round?

getNextLanes takes the root and returns the set of lanes to render now. It does not return everything that is pending. It returns only the highest-priority subset, and lower-priority lanes stay in pendingLanes for a later pass.

Lower bit number means higher priority. So among the eligible lanes, React isolates the group sitting on the lowest bits and hands back just those.

root.pendingLanes  = 0b1000001
                       │     │
                       │     └── bit 0  SyncLane         (highest)
                       └──────── bit 6  TransitionLane1  (lower)

getNextLanes(root) → 0b0000001     (lowest bit wins → SyncLane only)

   TransitionLane1 is NOT lost — it stays in pendingLanes,
   waiting for the NEXT call to getNextLanes.

Two filters before the choice

Before picking the highest priority, getNextLanes removes the lanes that are not eligible this round. It drops idle work, then drops anything currently blocked (suspended). Only what survives both filters competes to be chosen.

The real code

function getNextLanes(root, wipLanes) {
  const pendingLanes = root.pendingLanes;
  if (pendingLanes === NoLanes) return NoLanes;

  const nonIdlePendingLanes    = pendingLanes & NonIdleLanes;
  const nonIdleUnblockedLanes  = nonIdlePendingLanes & ~suspendedLanes;

  if (nonIdleUnblockedLanes !== NoLanes) {
    return getHighestPriorityLanes(nonIdleUnblockedLanes);  // highest only!
  }
  // ... handle idle / pinged lanes
  return nextLanes;
}

Read it as three steps:

  • pendingLanes & NonIdleLanes — keep only the non-idle lanes (idle work is saved for last).
  • & ~suspendedLanes — remove any lane that is currently blocked. ~ flips the suspended bits to zero, and AND clears them.
  • getHighestPriorityLanes(...) — of what remains, return only the most urgent group. This is the actual choice.

If nothing non-idle is unblocked, the function falls through to the idle and pinged cases (a pinged lane is one that was suspended and is now ready to retry). The headline path, though, is the one above: non-idle, unblocked, highest priority.

The choice is made at two levels

It helps to see getNextLanes as level one of a two-level filter. Level one happens once per render and decides the batch. Level two happens at every fiber and decides whether that fiber participates.

Level 1 — the FiberRoot. getNextLanes reads root.pendingLanes and returns the highest-priority subset. That returned set becomes renderLanes for the whole pass.

Level 2 — each fiber. As React walks the tree, beginWork asks one question per fiber: does this fiber have an update in the lanes we are rendering? That is a single bitwise AND.

The real code

// Inside beginWork:
function checkScheduledUpdateOrContext(current, renderLanes) {
  return includesSomeLane(current.lanes, renderLanes);
  // (current.lanes & renderLanes) !== 0
}

// If false → attemptEarlyBailoutIfNoScheduledUpdate()
// = skip this fiber (and walk childLanes to check subtree)

So the lanes that getNextLanes picked at level one become the yardstick every fiber is measured against at level two. Pick SyncLane, and only fibers with SyncLane work render; everyone else is skipped. Here is the full picture for one update that carries two lanes:

FiberRoot.pendingLanes = 0b1000001
                         │     │
                         │     └── SyncLane (high priority)
                         └──────── TransitionLane (low priority)

getNextLanes() → 0b0000001 (highest priority only)

┌─────────────────────────────────────────────────┐
│ RENDER PASS 1   renderLanes = 0b0000001         │
│                                                 │
│  { lane: 0b0000001, action: 'r'        }        │
│    └─ 0b0000001 & 0b0000001 = ✅ APPLY          │
│                                                 │
│  { lane: 0b1000000, action: filter()   }        │
│    └─ 0b1000000 & 0b0000001 = ❌ SKIP → base   │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│ RENDER PASS 2   renderLanes = 0b1000000         │
│                                                 │
│  { lane: 0b1000000, action: filter()   }        │
│    └─ 0b1000000 & 0b1000000 = ✅ APPLY          │
└─────────────────────────────────────────────────┘

Pass 1 renders the SyncLane update and defers the transition. The transition stays in pendingLanes, so the next getNextLanes call returns it, and pass 2 renders it. One choice, made repeatedly, produces React's multi-pass rendering.

Entanglement: lanes that must travel together

Usually getNextLanes is free to pick just the top group and leave the rest. But sometimes two lanes cannot be separated — rendering one without the other would show the user an inconsistent in-between state. React calls this entanglement.

When two lanes are entangled, React makes getNextLanes fold both bits into the set it returns, so they render and commit in the same pass. You can already see the seed of this idea in the function above: the suspendedLanes and pingedLanes fields exist precisely so React can hold a group of lanes back together and release them together. Entanglement is the same machinery pointed at "must run together" instead of "must wait together."

A concrete cause you have already met: in lesson 3.2, every startTransition inside the same event shared one transition lane (currentEventTransitionLane). That is React deliberately tying related updates to a single bit so they can never be split across passes.

What the chosen set then drives

The set getNextLanes returns is not just a render filter. It is the input to two other decisions.

It picks sync vs concurrent. When React schedules the root, it calls getNextLanes first, then branches on what came back: a SyncLane result is flushed synchronously with no Scheduler involvement, while anything else is handed to the Scheduler as a prioritized task.

Show the full source — getNextLanes deciding the scheduling path
// R18: inside ensureRootIsScheduled
// R19: scheduleTaskForRootDuringMicrotask
function scheduleTaskForRootDuringMicrotask(root, currentTime) {
  const nextLanes = getNextLanes(root, ...);

  if (includesSyncLane(nextLanes)) {
    // ── SYNC PATH ──────────────────────────────────
    root.callbackNode     = null;   // no Scheduler involvement
    root.callbackPriority = SyncLane;
    return SyncLane;
    // Flushed synchronously via flushSyncCallbacks()

  } else {
    // ── CONCURRENT PATH ────────────────────────────
    let schedulerPriorityLevel;
    switch (lanesToEventPriority(nextLanes)) {
      case DiscreteEventPriority:   schedulerPriorityLevel = ImmediateSchedulerPriority;   break;
      case ContinuousEventPriority: schedulerPriorityLevel = UserBlockingSchedulerPriority; break;
      case DefaultEventPriority:    schedulerPriorityLevel = NormalSchedulerPriority;       break;
      case IdleEventPriority:       schedulerPriorityLevel = IdleSchedulerPriority;         break;
    }
    const newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root),
    );
    root.callbackNode = newCallbackNode;
    return newCallbackPriority;
  }
}

It decides resume vs throw away. Concurrent rendering can pause and come back. When it comes back, React compares the lanes it should render now against the lanes it was rendering before it paused. If a newer, higher-priority update has arrived, those two no longer match, and React abandons the half-finished work to start fresh at the higher priority.

The real code

function renderRootConcurrent(root, lanes) {
  // Resume condition: same root AND same lanes as when we yielded
  if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
    // Conditions changed → cannot resume; start fresh
    prepareFreshStack(root, lanes);
  }

  // Work loop — yields when Scheduler says time is up
  workLoopConcurrent();
}

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
  // workInProgress pointer preserved across the yield
}

The lanes passed in here came from getNextLanes. So a single, fresh call to "which work is most urgent?" is what tips React between quietly continuing and tearing down a transition to serve a click. The choice of lanes is the choice of what to do.