⚛ The Course  /  Module 5 · A Full Update, End to End · 5.1
Module 5 · A Full Update, End to End

5.1 From setState to a scheduled render

You call setCount(1) and a moment later the screen shows 1. But what actually happens in between? This lesson follows the very first leg of that journey: from the instant you call your setter to the moment a re-render has been booked — not yet run. It turns out React does surprisingly little work right away, and that "little" is the whole trick.

setState doesn't render — it writes a note

The most common misconception is that setCount(1) immediately re-runs your component. It does not. React's first move is to create a tiny plain object — an update — that records what you asked for, and to append it to a queue.

Every setter you got from useState is really a bound copy of one internal function: dispatchSetState. When you call it, the value (or updater function) you passed becomes the update's action:

update = {
  lane:          <priority of this update>   ← which lane (Module 3)
  action:        <the value or updater fn>   ← e.g. 1, or (n => n + 1)
  hasEagerState: false
  eagerState:    null
  next:          null                         ← pointer for the linked list
}

Notice action holds the thing you passed. If you called setCount(n => n + 1), the function itself is stored — React does not run it here. It will be applied much later, during the render. For now it's just data on a note.

The note goes on a queue — a circular list

The update is appended to the queue that lives on this hook (hook.queue). React keeps these updates as a circular singly-linked list: the last node's .next points back to the first. The queue's pending pointer always points at the last node added.

After one update:
  queue.pending → Update_A
                  Update_A.next → Update_A   ← circular

After two updates:
  queue.pending → Update_B                   ← pending always points to LAST
                  Update_B.next → Update_A   ← last → first
                  Update_A.next → Update_B   ← first → last

The circular shape lets React reach both ends in one step: the tail is queue.pending, and the head is queue.pending.next — no separate head pointer needed. (How the queue is later replayed to compute the new state is a job for a sibling lesson; here we only care that the note got filed.)

From one hook, up to the root

The update sits on one hook, on one fiber — one small node deep in the tree. But React always renders starting from the root. So how does the root learn that something far below it changed?

React walks upward. Starting at your fiber, it follows the fiber.return chain (each fiber's link to its parent) all the way to the top, merging this update's lane into every ancestor's childLanes as it goes. This leaves a breadcrumb trail of "there is pending work below me" from your fiber up to the FiberRoot.

your fiber ──return──▶ parent ──return──▶ … ──return──▶ HostRoot ──▶ FiberRoot
   lanes |= lane        childLanes |= lane        childLanes |= lane

  (this walk is markUpdateLaneFromFiberToRoot; it returns the FiberRoot)

In the source this walk is done by enqueueConcurrentHookUpdate, which both registers the update and returns the FiberRoot at the top (or null if there's nothing to schedule). That returned root is the handle React needs for the next step.

Tell the root, then ask the scheduler

With the root in hand, dispatchSetState calls scheduleUpdateOnFiber(root, …). This does two things, in order:

  • markRootUpdated — records the lane on the root itself: root.pendingLanes |= lane. Now the root knows which priorities have work waiting.
  • ensureRootIsScheduled — the bell. It makes sure exactly one render is booked for this root.

ensureRootIsScheduled is where the path crosses over into Module 4's world. It asks getNextLanes which pending lanes to work on next, then hands a callback to the SchedulerscheduleCallback(performConcurrentWorkOnRoot) — so the render will run in a future time-slice.

setCount(1)
   │
   ▼  dispatchSetState
   ├─ build update { lane, action, … }
   ├─ enqueueConcurrentHookUpdate  → append to hook.queue + walk to root
   ▼  scheduleUpdateOnFiber
   ├─ markRootUpdated              → root.pendingLanes |= lane
   ▼  ensureRootIsScheduled
   └─ scheduleCallback(performConcurrentWorkOnRoot)   ← render is BOOKED, not run

That's the finish line for this lesson. No component function has re-run. No DOM has changed. A task is simply sitting in the Scheduler, waiting for its turn. When that turn comes, the render walks the queue and computes the new state — but that's the next leg of the trip.

The real code

function dispatchSetState(fiber, queue, action) {
  var lane = requestUpdateLane(fiber); // priority of this update

  var update = {
    lane: lane,
    action: action,        // the new value or updater function
    hasEagerState: false,
    eagerState: null,
    next: null,
  };

  // Add to the circular linked list on the hook's queue
  var root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);

  scheduleUpdateOnFiber(root, fiber, lane, eventTime); // request a render
}

The real code

function scheduleUpdateOnFiber(root, fiber, lane, eventTime) {
  markRootUpdated(root, lane, eventTime); // sets root.pendingLanes
  ensureRootIsScheduled(root, eventTime);
}

function ensureRootIsScheduled(root, currentTime) {
  var nextLanes = getNextLanes(root, ...); // highest-priority pending lanes

  // Hand off to the Scheduler (Chapter 8)
  var newCallbackNode = scheduleCallback$1(
    schedulerPriorityLevel,
    performConcurrentWorkOnRoot.bind(null, root)
  );
}
Show the full dispatchSetState (all five steps, with the eager-bailout check)
function dispatchSetState(fiber, queue, action) {
  var lane = requestUpdateLane(fiber);

  // Step 1 — allocate the update object
  var update = {
    lane: lane,
    action: action,       // the value or updater function you passed
    hasEagerState: false,
    eagerState: null,
    next: null            // circular linked list pointer
  };

  // Step 2 — render-phase update? (setState called during render)
  if (isRenderPhaseUpdate(fiber)) {
    // fiber === currentlyRenderingFiber → same component updating itself
    enqueueRenderPhaseUpdate(queue, update);
    // Sets didScheduleRenderPhaseUpdate = true; component loops up to 25 times
    return;
  }

  // Step 3 — EAGER STATE BAILOUT (only if no other updates pending)
  var alternate = fiber.alternate;
  if (
    fiber.lanes === NoLanes &&
    (alternate === null || alternate.lanes === NoLanes)
  ) {
    var lastRenderedReducer = queue.lastRenderedReducer;
    if (lastRenderedReducer !== null) {
      var currentState = queue.lastRenderedState;
      var eagerState = lastRenderedReducer(currentState, action);
      update.hasEagerState = true;
      update.eagerState = eagerState;

      if (objectIs(eagerState, currentState)) {
        // State is unchanged — skip scheduling entirely!
        enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update, lane);
        return;
      }
    }
  }

  // Step 4 — enqueue and mark lanes up the tree
  var root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
  // → markUpdateLaneFromFiberToRoot: walks fiber.return chain,
  //   merges lane into every ancestor's childLanes, returns FiberRoot

  // Step 5 — schedule
  if (root !== null) {
    scheduleUpdateOnFiber(root, fiber, lane, eventTime);
    // → ensureRootIsScheduled(root):
    //     SyncLane  → scheduleSyncCallback + scheduleMicrotask(flushSyncCallbacks)
    //     Concurrent → scheduleCallback(performConcurrentWorkOnRoot)
  }
}
Show the complete journey (this lesson is steps 1–4)
1. setState(value)
   ↓
2. dispatchSetState  →  create update { lane, action, hasEagerState, eagerState }
   ↓
3. scheduleUpdateOnFiber  →  markRootUpdated (sets root.pendingLanes)
   ↓
4. ensureRootIsScheduled  →  scheduleCallback(performConcurrentWorkOnRoot)
   ↓
5. performConcurrentWorkOnRoot  →  getNextLanes → renderLanes
   ↓
6. renderRootConcurrent  →  workLoopConcurrent
   ↓
7. performUnitOfWork  →  beginWork  →  updateFunctionComponent
   ↓
8. Component(props)  ← YOUR FUNCTION RUNS
   ↓
9. useState()  →  updateState()  →  updateReducer()
   ↓
10. for each update in queue:
     isSubsetOfLanes(renderLanes, updateLane)?
       YES  →  apply update  →  newState = reducer(newState, action)
       NO   →  clone to baseQueue, extend fiber.lanes
   ↓
11. hook.memoizedState = newState
    return [newState, dispatch]

Steps 5–11 (the render that actually replays the queue) belong to later lessons. Here we stop at step 4: the render is scheduled.