cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

What setState really does

Trace setState in React 19.3.1's source: the update object, the circular queue, the eager bailout, scheduling the root, and how the next render applies it.

By the end, you can
  • 01
    Follow a set call into ReactName what dispatchSetState does: pick a lane, make an update object, try the eager state, queue it, and schedule the root.
  • 02
    Read the update queueExplain the ring queue.pending points at, why updates wait in an array first, and why many set calls make one render.
  • 03
    Work through a queue like ReactApply a queue the way updateReducerImpl does: values and updaters in order, eager states, and skipped updates that rebase later.

Three set calls, one render

Queueing a series of state updates showed what happens when you call a set function three times: React waits for your handler, queues the three updates, and renders once. How hooks work inside showed where the queue lives: on the useState hook, in hook.queue. This lesson follows one set call through React 19.3.1's source, from your click to the render that applies it. Click Add 3 teas:

Add 3 teas, with a spy on the update queuePop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import { UpdateSpy } from "./UpdateSpy.jsx"; function TeaCart() {  const [count, setCount] = useState(0);   function addThree() {    setCount((c) => c + 1);    setCount((c) => c + 1);    setCount((c) => c + 1);  }   return <button onClick={addThree}>Add 3 teas ({count})</button>;} export default function App() {  return (    <UpdateSpy>      <TeaCart />    </UpdateSpy>  );}
ResultLoading…

Lines 8 to 10 call setCount three times with an updater function. Line 19 puts TeaCart inside UpdateSpy, from the UpdateSpy.jsx tab, which watches the queue of TeaCart's first hook and lists what React does with it. (Like the hooks lesson's inspector, it reads private fields: for looking only.) After one click, the log has five lines:

  1. After the handler: the fiber's lanes is 2 and so is root.pendingLanes, but queue.pending is still null. React knows work is waiting, but the updates aren't in the queue yet.
  2. Three Ring lines: when the render starts, React links the three update objects into a ring, one at a time. Each has lane 2 and your updater as its action. The first also has eagerState 1.
  3. A render took the ring: the render moved the updates out of queue.pending to work through them. The button now says Add 3 teas (3).

The rest of this lesson explains each line. Here is the whole path first:

Six cards in two rows. Top row, left to right: setCount, called in dispatchSetState; an update object with lane 2; concurrentQueues, where it waits. An arrow leads down to the bottom row, right to left: root.pendingLanes 2, set by scheduleUpdateOnFiber; one microtask, from ensureRootIsScheduled; and updateReducerImpl, the next render, which applies the queue.setCount(c => c + 1)update, lane 2concurrentQueuesdispatchSetStatelane, action, nextwaits hereroot.pendingLanes 21 microtaskupdateReducerImplscheduleUpdateOnFiberensureRootIsScheduledthe next render
One setCount call, start to finish. It makes an update object, which waits in concurrentQueues. It marks the root with the update's lane and makes sure one microtask is scheduled. In that microtask, React renders: the update joins the hook's ring, and updateReducerImpl applies it.
Real-life analogyRequests at a hotel front desk

A guest calls the front desk three times. The clerk writes each request on a slip, marks how urgent it is, and drops it in a tray. The first slip makes the clerk ring housekeeping's bell; the next two don't ring it again. When housekeeping comes, they take the room's slips and work through them in the order they were written. Urgent ones are done now, and the rest wait, in order, for the next round.

In real life: A request slip: what the guest wants, and how urgent it is
In React: An update object: its action and its lane
In real life: The tray where slips wait at the desk
In React: concurrentQueues, where updates wait until the render starts
In real life: Ringing housekeeping's bell once, however many slips arrive
In React: Scheduling one microtask, however many set calls you make
In real life: Throwing away a slip for something the room already has
In React: The eager bailout: the same state, so no render
In real life: Housekeeping going through the room's slips in order, keeping the not-urgent ones for the next round
In React: updateReducerImpl applying the queue, and keeping updates whose lane isn't in this render
The main idea

A set call doesn't change state. It queues an update and asks for a render. dispatchSetState picks a lane, makes an update object, and, if the queue is empty, works out the next state early and stops if nothing changed. Otherwise the update waits, the root is marked, and one microtask is scheduled. The next render links the updates into the hook's ring and applies them in order, skipping any whose lane that render doesn't include.

setCount is dispatchSetState, bound to one hook

Where does setCount come from? mountState makes it on the first render, right after mountStateImpl made the hook and its queue. This is the whole function, at ReactFiberHooks.js, lines 1948 to 1960:

mountState, ReactFiberHooks.js lines 1948 to 1960JavaScript
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ) as any;
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}
  1. Line 4 makes the hook and its queue. Line 5 takes the queue.
  2. Lines 6 to 10 make the set function: dispatchSetState.bind(null, currentlyRenderingFiber, queue). bind makes a new function with the first arguments fixed in place (see call, apply, and bind): this component's fiber and this hook's queue. When you call setCount(5), React gets dispatchSetState(fiber, queue, 5).
  3. Line 11 keeps it in queue.dispatch, and line 12 returns it next to the state. Later renders return queue.dispatch again, so it's the same function every time.

See it on a live set function. Click Render again:

What a set function really isPop out in the code editor (opens in a new tab)JSX
import { useReducer, useState } from "react"; function cartReducer(items, tea) {  return [...items, tea];} export default function App() {  const [count, setCount] = useState(0);  const [items, addItem] = useReducer(cartReducer, []);  const [firstSetCount] = useState(() => setCount);   return (    <>      <p>setCount.name: {setCount.name}</p>      <p>addItem.name: {addItem.name}</p>      <p>The same setCount as on the first render: {String(setCount === firstSetCount)}</p>      <button onClick={() => setCount(count + 1)}>Render again ({count})</button>    </>  );}
ResultLoading…

Line 14 prints bound dispatchSetState: a function made with bind is named after the function it binds. Line 15 prints bound dispatchReducerAction, useReducer's version. Line 10 keeps the first render's setCount (an initializer function runs only on mount), and line 16 compares: true, even after you click. That's why setCount is safe to leave out of an Effect's dependencies.

When you call it, dispatchSetState does two things. This is the function, shortened from lines 3626 to 3654; the cut lines warn about a second argument and tell DevTools about the update.

dispatchSetState, shortened from ReactFiberHooks.jsJavaScript
function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  // …
  const lane = requestUpdateLane(fiber);
  const didScheduleUpdate = dispatchSetStateInternal(
    fiber,
    queue,
    action,
    lane,
  );
  // …
}

Line 7 asks requestUpdateLane how urgent this update is; the next section shows the answer. Lines 8 to 13 hand everything to dispatchSetStateInternal, which does the rest of this lesson's work: it makes the update, tries the eager state, queues the update, and schedules the root.

Each call makes an update object

Every set call makes one plain object. Here is its type, and the type of the queue it joins, at lines 167 to 183:

Update and UpdateQueue, ReactFiberHooks.js lines 167 to 183JavaScript
export type Update<S, A> = {
  lane: Lane,
  revertLane: Lane,
  action: A,
  hasEagerState: boolean,
  eagerState: S | null,
  next: Update<S, A>,
  gesture: null | ScheduledGesture, // enableGestureTransition
};

export type UpdateQueue<S, A> = {
  pending: Update<S, A> | null,
  lanes: Lanes,
  dispatch: (A => mixed) | null,
  lastRenderedReducer: ((S, A) => S) | null,
  lastRenderedState: S | null,
};

And here dispatchSetStateInternal makes one, at lines 3662 to 3670:

dispatchSetStateInternal makes the update, ReactFiberHooks.js lines 3662 to 3670JavaScript
  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    gesture: null,
    action,
    hasEagerState: false,
    eagerState: null,
    next: null as any,
  };

Line 2 is the lane from dispatchSetState, and line 5 is your action: whatever you passed, a value or a function. Lines 6 and 7 start with no eager state, and line 8 with no next. How to read the table: each row is one field, what it holds, and its value for the first update of Add 3 teas.

The fields of an update object
FieldWhat it holdsIn the Add 3 teas click
laneHow urgent the update is, as one bit: 2 for a click, 32 for a timer, 256 or another transition bit for a transition.2
revertLaneOnly for useOptimistic: the lane whose end removes the optimistic value. A plain set call leaves it at NoLane, 0.0
actionWhat you passed to the set function: a value, or an updater function.(c) => c + 1
hasEagerState, eagerStateWhether React already worked out the next state while the queue was empty, and that state.true and 1 for the first update; false for the other two.
nextThe next update in the ring.The next update; the last one points back at the first.
gestureFor an experimental feature, enableGestureTransition. Always null in a stable release.null

The queue (lines 11 to 17 of the types) keeps pending, the ring of updates; lanes, used to tie transitions together; dispatch, your set function; and the reducer and state of the last render, which the eager state uses.

A lane is a priority written as one bit of a number. You'll meet lanes properly in the lanes lesson; for now, a few values are enough. They are in ReactFiberLane.js:

Some lanes, shortened from ReactFiberLane.jsJavaScript
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010;
// …
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000;
// …
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000;
// …
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000100000000;

Read each number from the right: SyncLane (line 1) is the second bit, 2. InputContinuousLane (line 3) is 8, for continuous input such as scrolling and dragging. DefaultLane (line 5) is 32. TransitionLane1 (line 7) is 256, the first of several transition lanes. Which one an update gets depends on where your set call runs. Try all three buttons, then press Transition a second time:

Where the set call runs decides its lanePop out in the code editor (opens in a new tab)JSX
import { startTransition, useState } from "react";import { UpdateSpy } from "./UpdateSpy.jsx"; function Points() {  const [points, setPoints] = useState(10);   return (    <p>      Points: {points}{" "}      <button onClick={() => setPoints((p) => p + 1)}>Click</button>{" "}      <button onClick={() => setTimeout(() => setPoints((p) => p + 2), 0)}>Timer</button>{" "}      <button onClick={() => startTransition(() => setPoints((p) => p + 3))}>Transition</button>    </p>  );} export default function App() {  return (    <UpdateSpy>      <Points />    </UpdateSpy>  );}
ResultLoading…

Click (line 10) gives lane 2: a click is a discrete event, the most urgent kind. Timer (line 11) calls setPoints later, outside any event, so it gets the default, lane 32; its "After the handler" line shows 0 everywhere, because the handler only started a timer. Transition (line 12) gives lane 256, and the next transition lane 512: React hands out the transition lanes in turn.

Updates wait, then join a ring

After the eager state (next section), dispatchSetStateInternal queues the update and schedules the root, at lines 3718 to 3723:

Queue it, then schedule the root, ReactFiberHooks.js lines 3718 to 3723JavaScript
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      scheduleUpdateOnFiber(root, fiber, lane);
      entangleTransitionUpdate(root, queue, lane);
      return true;
    }

Line 1 queues the update and gets back the root of the tree. Line 3 schedules that root, and line 4 matters only for transitions. Queuing happens in enqueueUpdate, at ReactFiberConcurrentUpdates.js, lines 90 to 113:

enqueueUpdate, ReactFiberConcurrentUpdates.js lines 90 to 113JavaScript
function enqueueUpdate(
  fiber: Fiber,
  queue: ConcurrentQueue | null,
  update: ConcurrentUpdate | null,
  lane: Lane,
) {
  // Don't update the `childLanes` on the return path yet. If we already in
  // the middle of rendering, wait until after it has completed.
  concurrentQueues[concurrentQueuesIndex++] = fiber;
  concurrentQueues[concurrentQueuesIndex++] = queue;
  concurrentQueues[concurrentQueuesIndex++] = update;
  concurrentQueues[concurrentQueuesIndex++] = lane;

  concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);

  // The fiber's `lane` field is used in some places to check if any work is
  // scheduled, to perform an eager bailout, so we need to update it immediately.
  // TODO: We should probably move this to the "shared" queue instead.
  fiber.lanes = mergeLanes(fiber.lanes, lane);
  const alternate = fiber.alternate;
  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane);
  }
}
  1. Lines 9 to 12 push four things into an array, concurrentQueues: the fiber, the queue, the update, and the lane. The update is not in queue.pending yet. The comment on lines 7 and 8 says why: a render might be running right now, and React doesn't change its queues halfway through.
  2. Lines 19 to 23 mark the fiber's lanes straight away, on both copies of the fiber. Lines 16 and 17 give the reason: the eager bailout checks this field. That's the fiber.lanes 2 the spy saw after the handler.

The updates join the hook's queue when the next render starts, in finishQueueingConcurrentUpdates, which goes through the array four entries at a time. This is the part that links one update, at lines 68 to 78:

Linking an update into the ring, ReactFiberConcurrentUpdates.js lines 68 to 78JavaScript
    if (queue !== null && update !== null) {
      const pending = queue.pending;
      if (pending === null) {
        // This is the first update. Create a circular list.
        update.next = update;
      } else {
        update.next = pending.next;
        pending.next = update;
      }
      queue.pending = update;
    }
  1. Line 2 reads queue.pending. If it's null, line 5 points the update at itself: a ring of one.
  2. Otherwise pending is the newest update, and pending.next the oldest. Line 7 points the new update at the oldest, and line 8 points the old newest at the new one.
  3. Line 10 makes the new update the newest: queue.pending now points at it.
Three update cards in a triangle: update 1 at top left, update 2 at top right, update 3 at the bottom. Arrows go from 1 to 2, from 2 to 3, and from 3 back to 1. A label queue.pending points at update 3.1: c => c + 12: c => c + 13: c => c + 1nextnextnextqueue.pendingthe newest update
The ring after three setCount calls. queue.pending points at the newest update, number 3. Its next points back at the oldest, number 1, so pending.next is where React starts. Every update's next leads to the one queued after it.

Why a ring? With pending pointing at the newest update, adding one at the end takes two pointer changes, and finding the first is one step, pending.next. No array to grow, no walk to the end. The spy's three Ring lines are lines 2 to 10 running three times: each line is the ring right after line 10, read from pending.next. The spy itself is watch in the UpdateSpy.jsx tab: it replaces queue.pending with a getter and a setter (see Getters and setters), so it hears every time React sets it.

The eager state bailout

Before queuing, dispatchSetStateInternal tries a shortcut. If nothing else is waiting, it works out the next state right away, and if that state is the same as the current one, it doesn't render at all. This is the eager state, shortened from lines 3675 to 3716; the cut lines swap the development dispatcher and hold longer comments.

The eager state, shortened from ReactFiberHooks.jsJavaScript
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // The queue is currently empty, which means we can eagerly compute the
      // next state before entering the render phase. If the new state is the
      // same as the current state, we may be able to bail out entirely.
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        // …
        try {
          const currentState: S = queue.lastRenderedState as any;
          const eagerState = lastRenderedReducer(currentState, action);
          // …
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            // Fast path. We can bail out without scheduling React to re-render.
            // …
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return false;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          // …
        }
      }
    }
  1. Lines 2 to 5: only if fiber.lanes and the other copy's lanes are both empty. Then nothing is waiting for this component, so the next state depends only on this update.
  2. Lines 13 and 14 take the state of the last render and call lastRenderedReducer with your action, right now, inside your set call.
  3. Lines 16 and 17 keep the result on the update: hasEagerState and eagerState.
  4. Line 18 compares it with the current state using Object.is (see Equality). If they're the same, line 21 parks the update without scheduling anything, and line 22 returns: no render.
  5. Lines 24 and 25: if your updater throws, React ignores it here; it will throw again during the render.

For useState, lastRenderedReducer is basicStateReducer, at lines 1265 to 1268:

basicStateReducer, ReactFiberHooks.js lines 1265 to 1268JavaScript
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
  return typeof action === 'function' ? action(state) : action;
}

Line 3 is the rule from the queueing lesson, in one line: a function is called with the state, anything else replaces it. So the eager state calls your updater during the click. Watch it. This updater writes where it runs into a list, which no real updater should do; it's only to see. Click Add tea three times:

Where does the updater run?Pop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; const log = [];let inHandler = false; export default function App() {  const [count, setCount] = useState(0);   function addTea() {    inHandler = true;    setCount((c) => {      log.push(inHandler ? "in the click handler" : "while rendering");      return c + 1;    });    inHandler = false;  }   return (    <>      <button onClick={addTea}>Add tea ({count})</button>      <ol>        {log.map((where, index) => (          <li key={index}>{where}</li>        ))}      </ol>    </>  );}
ResultLoading…

Lines 10 and 15 mark the time inside the handler. After the first click, the list says in the click handler: the eager state called the updater on line 11. Then while rendering: in development, StrictMode calls each updater once more during the render and throws the result away. After the second click, two more lines, both while rendering. No eager state this time: the render the first click caused left its lane on the copy of the fiber that was on screen before, and that copy keeps it until React copies the fiber again. Line 2 of the excerpt saw it, so React skipped the shortcut and called the updater during the render.

Now set a value that doesn't change. Stats writes two counters into the page every 100 ms without rendering. Click Set the same count, then Add one, then Set the same count twice:

Setting the same valuePop out in the code editor (opens in a new tab)JSX
import { useEffect, useRef, useState } from "react"; const counts = { calls: 0, effects: 0 }; function Counter() {  counts.calls++;  const [count, setCount] = useState(0);  useEffect(() => {    counts.effects++;  });   return (    <p>      Count: {count}{" "}      <button onClick={() => setCount(count)}>Set the same count</button>{" "}      <button onClick={() => setCount(count + 1)}>Add one</button>    </p>  );} // Writes the counters into its paragraph every 100 ms. It never renders again,// so it doesn't change what React does with Counter.function Stats() {  const line = useRef(null);  useEffect(() => {    const timer = setInterval(() => {      line.current.textContent = "Counter was called " + counts.calls + " times. Its effect ran " + counts.effects + " times.";    }, 100);    return () => clearInterval(timer);  }, []);  return <p ref={line} />;} export default function App() {  return (    <>      <Counter />      <Stats />    </>  );}
ResultLoading…
  1. At first, Counter was called 2 times and its effect ran 2 times: StrictMode calls components and runs effects twice when they mount, in development.
  2. Set the same count: nothing changes. The eager state was 0, the same as the state, so line 22 of the excerpt returned before scheduling anything. Counter wasn't even called.
  3. Add one: called 4 times, effect 3 times. A normal render.
  4. Set the same count: called 6 times, effect still 3. The old copy still had a lane, so React skipped the shortcut and rendered. In the render, useState worked out the same state, so React stopped there: it didn't render Counter's children (the reconciliation lesson covers these bailouts), and the effect didn't run. This render also cleared the old lane.
  5. Set the same count again: still 6. Both copies are clean now, so the shortcut works.

That is what react.dev means when it says React may still need to call your component before skipping its children. How to read the table: each row is a situation, whether the shortcut is tried, and what follows.

When the eager state is tried
When you call the set functionDoes React try the eager state?What happens next
The first set call, when neither copy of the fiber has a pending laneYes.If the result is the same (Object.is), nothing renders. Otherwise the update keeps its eagerState for the render.
A second or later set call in the same handlerNo: the first call already set fiber.lanes.The update joins the queue, and the render calls your updater.
A set call soon after this component rendered, while the copy that was on screen before still has its old laneNo.React renders. If the state comes out the same, it stops after calling the component, before its children.
Any call of useReducer's dispatchNever: dispatchReducerAction has no eager branch.React renders, and stops after calling the component if the reducer returns the same state.

The rule for your code: the eager bailout is a speed-up React applies when it can, not a promise. A set call with the same value is cheap either way, and an updater must be pure, because React may call it in your handler, during the render, or both.

scheduleUpdateOnFiber tells the root

Rendering always starts at the root of the tree, so the root has to know there is work. enqueueConcurrentHookUpdate found the root by climbing the fiber's return pointers, and now scheduleUpdateOnFiber marks it. Shortened from ReactFiberWorkLoop.js, lines 987 to 1113:

scheduleUpdateOnFiber, shortened from ReactFiberWorkLoop.jsJavaScript
export function scheduleUpdateOnFiber(
  root: FiberRoot,
  fiber: Fiber,
  lane: Lane,
) {
  // …
  // Mark that the root has a pending update.
  markRootUpdated(root, lane);
  // …
    ensureRootIsScheduled(root);
  // …
}

Line 8 marks the root with the lane. Line 10 makes sure the root will be rendered. The cut lines handle a render that is already running or waiting for data, and DevTools. markRootUpdated is in ReactFiberLane.js:

markRootUpdated, shortened from ReactFiberLane.jsJavaScript
export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
  root.pendingLanes |= updateLane;
  // …
}

Line 2 adds the lane to root.pendingLanes with |=, a bitwise OR: it switches the bit on and leaves the others alone. That is the spy's root.pendingLanes 2. But the spy also showed parent's childLanes 0. The fibers between the root and TeaCart are marked later, when the render starts, by markUpdateLaneFromFiberToRoot, shortened from lines 189 to 250:

markUpdateLaneFromFiberToRoot, shortened from ReactFiberConcurrentUpdates.jsJavaScript
function markUpdateLaneFromFiberToRoot(
  sourceFiber: Fiber,
  update: ConcurrentUpdate | null,
  lane: Lane,
): null | FiberRoot {
  // Update the source fiber's lanes
  sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
  let alternate = sourceFiber.alternate;
  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane);
  }
  // Walk the parent path to the root and update the child lanes.
  let isHidden = false;
  let parent = sourceFiber.return;
  let node = sourceFiber;
  while (parent !== null) {
    parent.childLanes = mergeLanes(parent.childLanes, lane);
    alternate = parent.alternate;
    if (alternate !== null) {
      alternate.childLanes = mergeLanes(alternate.childLanes, lane);
    }
    // …
    node = parent;
    parent = parent.return;
  }
  // …
}
  1. Lines 7 to 11 mark the fiber itself, on both copies, again.
  2. Lines 16 to 25 climb from parent to parent. Line 17 adds the lane to each parent's childLanes: "somewhere below me, there is work in this lane". Line 20 does the same on the parent's other copy.

finishQueueingConcurrentUpdates calls it for each waiting update, right after linking it into the ring. While React renders, childLanes tells it which parts of the tree it can skip: a parent whose childLanes don't include this render's lanes has no work below it (the work loop lesson walks the tree).

Two columns, split by a dashed line. Left, after the handler: TeaCart's lanes 2 at the bottom, the parents' childLanes 0 in the middle, root.pendingLanes 2 at the top. Right, when the render starts: the same, but the parents' childLanes are 2.after the handlerroot.pendingLanes 2parents' childLanes 0TeaCart's lanes 2when the render startsroot.pendingLanes 2parents' childLanes 2TeaCart's lanes 2
Right after your handler, the update's lane, 2, is on TeaCart's fiber and on the root, but the fibers in between still say 0. When the render starts, markUpdateLaneFromFiberToRoot walks up and marks each parent's childLanes, so the render can find its way down to TeaCart.

How to read the table: each row is a field React marks for an update, who marks it, and when.

Who marks what, and when
FieldWho marks itWhen
fiber.lanes, on both copies of the fiberenqueueUpdate, in ReactFiberConcurrentUpdates.jsRight away, inside your set call.
root.pendingLanesmarkRootUpdated, called by scheduleUpdateOnFiberRight away, inside your set call.
The parents' childLanes, up to the rootmarkUpdateLaneFromFiberToRootWhen the next render starts, in finishQueueingConcurrentUpdates.
queue.pending, the ringfinishQueueingConcurrentUpdatesWhen the next render starts.

Batching: one microtask for many updates

ensureRootIsScheduled adds the root to a list of roots with work, then calls ensureScheduleIsScheduled, at ReactFiberRootScheduler.js, lines 154 to 169:

ensureScheduleIsScheduled, ReactFiberRootScheduler.js lines 154 to 169JavaScript
export function ensureScheduleIsScheduled(): void {
  // At the end of the current event, go through each of the roots and ensure
  // there's a task scheduled for each one at the correct priority.
  if (__DEV__ && ReactSharedInternals.actQueue !== null) {
    // We're inside an `act` scope.
    if (!didScheduleMicrotask_act) {
      didScheduleMicrotask_act = true;
      scheduleImmediateRootScheduleTask();
    }
  } else {
    if (!didScheduleMicrotask) {
      didScheduleMicrotask = true;
      scheduleImmediateRootScheduleTask();
    }
  }
}

Lines 4 to 9 are for tests that use act. In an app, lines 11 to 14 run: if no microtask is scheduled yet, schedule one and remember it in didScheduleMicrotask. The second and third setCount find the flag set and do nothing more. That is batching: all three updates wait for the same microtask (see Microtasks), which runs after your handler returns. Shortened from lines 259 to 348:

processRootScheduleInMicrotask, shortened from ReactFiberRootScheduler.jsJavaScript
function processRootScheduleInMicrotask() {
  // This function is always called inside a microtask. It should never be
  // called synchronously.
  didScheduleMicrotask = false;
  // …
  // At the end of the microtask, flush any pending synchronous work. This has
  // to come at the end, because it does actual rendering work that might throw.
  // …
  if (!hasPendingCommitEffects()) {
    flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
  }
  // …
}

Line 4 clears the flag, so the next event can schedule a new microtask. Lines 9 to 11, at the end, render the roots that have synchronous work, like a click's. Updates in other lanes, like a timer's or a transition's, get a task from the scheduler instead, which renders them a little later. You can see the microtask from your own code. This handler queues a microtask of its own before the first setCount, and another after it. Click the button:

When does React render a click's updates?Pop out in the code editor (opens in a new tab)JSX
import { useRef, useState } from "react"; export default function App() {  const [count, setCount] = useState(0);  const [notes, setNotes] = useState([]);  const button = useRef(null);   function addThree() {    const seen = [];    queueMicrotask(() => seen.push("A microtask queued before setCount sees: " + button.current.textContent));    setCount((c) => c + 1);    queueMicrotask(() => seen.push("A microtask queued after setCount sees: " + button.current.textContent));    setCount((c) => c + 1);    setCount((c) => c + 1);    seen.push("The end of the handler sees: " + button.current.textContent);    setTimeout(() => setNotes(seen), 0);  }   return (    <>      <button ref={button} onClick={addThree}>Add 3 teas ({count})</button>      <ol>        {notes.map((note, index) => (          <li key={index}>{note}</li>        ))}      </ol>    </>  );}
ResultLoading…
  1. The end of the handler sees: Add 3 teas (0). Line 15 runs after all three set calls, and nothing has rendered.
  2. A microtask queued before setCount sees: Add 3 teas (0). Line 10's microtask was queued before React's, so it runs first, still before the render.
  3. A microtask queued after setCount sees: Add 3 teas (3). Line 11's setCount queued React's microtask, so line 12's runs after it, and the page already shows 3: one render, with all three updates.

Line 16 shows the notes with a timer, after everything else. React 18 made this the rule for updates from anywhere: event handlers, timers, promises, and the code after await. The queueing lesson counted the renders; now you've seen the flag and the microtask that make it work.

The next render works through the queue

In that render, useState calls updateState, which calls updateReducerImpl with basicStateReducer. First it takes the ring out of queue.pending, shortened from lines 1333 to 1359:

updateReducerImpl takes the ring, shortened from ReactFiberHooks.jsJavaScript
  let baseQueue = hook.baseQueue;

  // The last pending update that hasn't been processed yet.
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    // We have new updates that haven't been processed yet.
    // We'll add them to the base queue.
    if (baseQueue !== null) {
      // Merge the pending queue and the base queue.
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    // …
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }
  1. Line 1: hook.baseQueue holds updates an earlier render skipped. It's usually null.
  2. Lines 8 to 14: if both exist, React joins the two rings into one, old updates first, by swapping two next pointers.
  3. Lines 16 and 17: the joined ring becomes the base queue, and queue.pending goes back to null. That is the spy's last line, A render took the ring.

Then the loop. This is a simplified version of lines 1372 to 1544: it leaves out updates made inside a hidden tree, useOptimistic and gesture updates, the StrictMode extra call, and comments. Every line it keeps is React's, except line 10, which drops the hidden-tree case from the check.

The loop in updateReducerImpl, simplified from ReactFiberHooks.jsJavaScript
    const first = baseQueue.next;
    let newState = baseState;

    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast: Update<S, A> | null = null;
    let update = first;
    do {
      const updateLane = removeLanes(update.lane, OffscreenLane);
      let shouldSkipUpdate = !isSubsetOfLanes(renderLanes, updateLane);

      if (shouldSkipUpdate) {
        const clone: Update<S, A> = {
          lane: updateLane,
          action: update.action,
          hasEagerState: update.hasEagerState,
          eagerState: update.eagerState,
          next: null as any,
        };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
      } else {
        if (newBaseQueueLast !== null) {
          const clone: Update<S, A> = {
            lane: NoLane,
            action: update.action,
            hasEagerState: update.hasEagerState,
            eagerState: update.eagerState,
            next: null as any,
          };
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }

        const action = update.action;
        if (update.hasEagerState) {
          newState = update.eagerState as any as S;
        } else {
          newState = reducer(newState, action);
        }
      }
      update = update.next;
    } while (update !== null && update !== first);
  1. Lines 1 to 8: start at the oldest update, baseQueue.next, with newState set to the base state.
  2. Line 10 asks whether this render includes the update's lane: isSubsetOfLanes(renderLanes, updateLane). A click's render has lane 2; a transition's update has lane 256, so it isn't included.
  3. Lines 12 to 29 skip it. Line 13 makes a copy, and lines 20 to 25 add it to a new base queue. If it's the first skipped update, line 22 saves the state so far as newBaseState: the next render will start from there. Lines 26 to 29 put the lane back on the fiber, so React knows to render again.
  4. Lines 30 to 47 apply it. If an earlier update was skipped, lines 31 to 40 copy this one too, with lane 0, which every render includes, so it runs again after the skipped one. Lines 43 to 47 work out the new state: the eager state if there is one, otherwise reducer(newState, action).
  5. Line 49 moves on, and line 50 stops when the loop gets back to the first update.

And the end, shortened from lines 1546 to 1578:

updateReducerImpl saves the result, shortened from ReactFiberHooks.jsJavaScript
    if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = newBaseQueueFirst as any;
    }

    // Mark that the fiber performed work, but only if the new state is
    // different from the current state.
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
      // …
    }

    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;

    queue.lastRenderedState = newState;

If nothing was skipped, lines 1 and 2 make the result the new base state. Line 9 tells React this component's state changed, only if it did: that's how the same-value render in the eager section stopped early. Lines 14 to 18 save the state, the base state, and the base queue on the hook. Step through it with three handlers. Pick one and press Step:

Playground: work through the queue like updateReducerImpl
The handler: Three updatersJavaScript
// count starts at 0
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
Inside updateReducerImplrenderLanes 2
UpdatelaneeagerStateThis render
(c) => c + 121waiting
(c) => c + 12nonewaiting
(c) => c + 12nonewaiting
newState
0
newBaseState
null

New base queue: updates kept for a later render

Empty: nothing was skipped.

Step 1 of 5the click's render (SyncLane, 2)
A render starts

React starts the click's render (SyncLane, 2). It takes the ring from queue.pending: 3 updates, starting from baseState 0.

A small model of updateReducerImpl's loop, written for this lesson. It replays the excerpts above; it isn't a view inside React. The lesson's test runs each handler with React 19.3.1 and checks every result the model shows.

Three updaters: the first update uses its eager state, 1, without calling anything; the other two are called with 1 and 2. A value, then an updater: 5 replaces the state, then (c) => c * 2 gets 5. A transition, then a click takes two renders. The click's render skips the transition's update, so the screen shows 10, with baseState 1 and two updates waiting. The transition's render starts from 1 again and applies both, in order: 20. React calls this rebasing: the waiting updates run again on top of the base state, so the result is the same as if they had all run in the order you queued them.

Two rows. The click's render with lane 2 starts from 1, skips s => s + 1, applies s => s * 10, and shows 10. The transition's render with lane 256 starts from 1 again, applies s => s + 1 and then s => s * 10, and shows 20.the click's render, lane 2: skips s + 11s => s + 1s => s * 1010the transition's render, lane 256: starts from 1 again1s => s + 1s => s * 1020
The score starts at 1. The click's render can't use the transition's update, so it skips it, keeps a copy, and shows 10. The transition's render starts again from the base state, 1, and applies both updates in the order you queued them: 20.

Here is the same handler in real React. Click Bonus:

A transition and a click in one handlerPop out in the code editor (opens in a new tab)JSX
import { startTransition, useLayoutEffect, useRef, useState } from "react";import { UpdateSpy } from "./UpdateSpy.jsx"; const screens = []; function Score() {  const [score, setScore] = useState(1);  const line = useRef(null);   useLayoutEffect(() => {    if (screens.at(-1) !== score) screens.push(score);    line.current.textContent = "Screens so far: " + screens.join(", ");  });   function bonus() {    startTransition(() => setScore((s) => s + 1));    setScore((s) => s * 10);  }   return (    <p>      <button onClick={bonus}>Bonus</button> Score: {score}. <span ref={line} />    </p>  );} export default function App() {  return (    <UpdateSpy>      <Score />    </UpdateSpy>  );}
ResultLoading…

Line 16 queues the transition's update and line 17 the click's. The page says Screens so far: 1, 10, 20: the layout effect on lines 10 to 13 records every score React committed. The spy shows the ring: lane 256 with eagerState 2 (the queue was empty, so the shortcut ran), then lane 2. After the handler, the fiber's lanes are 258: 256 and 2 together, two bits switched on.

An update that never renders

Knowing the eager bailout explains a classic bug. This cart adds an item by changing the array it already has. Click Add mint tea twice:

Changing the array in place (a bug on purpose)Pop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [items, setItems] = useState(["Green tea"]);   function addMint() {    items.push("Mint tea");    setItems(items);  }   return (    <>      <button onClick={addMint}>Add mint tea</button>      <p>        {items.length} items: {items.join(", ")}      </p>    </>  );}
ResultLoading…

The page keeps saying 1 items: Green tea. Line 7 pushes into the same array, and line 8 passes that same array to setItems. In the eager branch, basicStateReducer returns it, and Object.is(items, items) is true: no render. Even when the shortcut doesn't run, updateReducerImpl compares with is too, and React stops before the children render. Either way, React never sees a new value. Make a new array instead (see Updating arrays in state):

A new array every timePop out in the code editor (opens in a new tab)JSX
import { useState } from "react"; export default function App() {  const [items, setItems] = useState(["Green tea"]);   function addMint() {    setItems([...items, "Mint tea"]);  }   return (    <>      <button onClick={addMint}>Add mint tea</button>      <p>        {items.length} items: {items.join(", ")}      </p>    </>  );}
ResultLoading…

Line 7 makes a new array, so Object.is sees a different value, and the page shows 2 items: Green tea, Mint tea. The same goes for objects: change a copy, never the state itself. Now sort the steps of a set call into the parts of React that do them:

Which part of React does it?
  • Picks a lane with requestUpdateLane
  • Makes the update object { lane, action, … }
  • Works out eagerState while the queue is empty
  • Pushes fiber, queue, update, and lane into concurrentQueues
  • Links the update into queue.pending's ring
  • Sets root.pendingLanes |= lane
  • Makes sure one microtask is scheduled
  • Skips an update whose lane isn't in this render
  • Calls your updater function with the newest state
Try it yourself
0 of 9 correct

Sort each step of a set call into the part of React that does it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "setCount changes the state." No. It makes an update object and queues it. The state changes in the next render, in updateReducerImpl.
  • "After setCount, the update is in hook.queue.pending." No. It waits in concurrentQueues and joins the ring when the next render starts.
  • "Updaters always run during the render." No. When the queue is empty, React calls the updater inside your set call, for the eager state. That's one more reason updaters must be pure.
  • "Setting the same value never calls my component." No. The eager bailout skips the render only when both copies of the fiber are free of work. Otherwise React calls the component, sees the same state, and stops before its children render.
  • "React renders once per set call, then batches the paint." No. The first set call schedules one microtask, and that one render applies every queued update.
  • "An urgent update can jump ahead of a transition's update and change the order." No. It renders first, but the skipped update is kept, and the next render applies everything again in the order you queued it.

Practice exercises

Predict first, then press Edit & run to check. Every answer comes from a function this lesson showed.

Exercise 1 · Warm-upName useReducer's dispatch

Without looking at the result: what does line 15 print for addItem.name?

import { useReducer, useState } from "react";

function cartReducer(items, tea) {
  return [...items, tea];
}

export default function App() {
  const [count, setCount] = useState(0);
  const [items, addItem] = useReducer(cartReducer, []);
  const [firstSetCount] = useState(() => setCount);

  return (
    <>
      <p>setCount.name: {setCount.name}</p>
      <p>addItem.name: {addItem.name}</p>
      <p>The same setCount as on the first render: {String(setCount === firstSetCount)}</p>
      <button onClick={() => setCount(count + 1)}>Render again ({count})</button>
    </>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe lane of a timer

    Which lane does the update from the Timer button get? Predict, then press Edit & run and click it.

    import { startTransition, useState } from "react";
    import { UpdateSpy } from "./UpdateSpy.jsx";
    
    function Points() {
      const [points, setPoints] = useState(10);
    
      return (
        <p>
          Points: {points}{" "}
          <button onClick={() => setPoints((p) => p + 1)}>Click</button>{" "}
          <button onClick={() => setTimeout(() => setPoints((p) => p + 2), 0)}>Timer</button>{" "}
          <button onClick={() => startTransition(() => setPoints((p) => p + 3))}>Transition</button>
        </p>
      );
    }
    
    export default function App() {
      return (
        <UpdateSpy>
          <Points />
        </UpdateSpy>
      );
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeA value, then an updater

      The first click on Refill queues two updates. Which eagerState does the ring show, and on which update?

      import { useState } from "react";
      import { UpdateSpy } from "./UpdateSpy.jsx";
      
      function Cups() {
        const [cups, setCups] = useState(0);
      
        function refill() {
          setCups(5);
          setCups((c) => c * 2);
        }
      
        return <button onClick={refill}>Refill ({cups})</button>;
      }
      
      export default function App() {
        return (
          <UpdateSpy>
            <Cups />
          </UpdateSpy>
        );
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeuseReducer and the same state

        The size is already Small. After one click on Choose Small, how many times has CupSize been called in total?

        import { useEffect, useReducer, useRef } from "react";
        
        const counts = { calls: 0 };
        
        function sizeReducer(size, next) {
          return next;
        }
        
        function CupSize() {
          counts.calls++;
          const [size, choose] = useReducer(sizeReducer, "Small");
          return (
            <p>
              Size: {size} <button onClick={() => choose("Small")}>Choose Small</button>
            </p>
          );
        }
        
        function Stats() {
          const line = useRef(null);
          useEffect(() => {
            const timer = setInterval(() => {
              line.current.textContent = "CupSize was called " + counts.calls + " times.";
            }, 100);
            return () => clearInterval(timer);
          }, []);
          return <p ref={line} />;
        }
        
        export default function App() {
          return (
            <>
              <CupSize />
              <Stats />
            </>
          );
        }

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeRebase by hand

          The score starts at 3. Click Bonus once. What does Screens so far show?

          import { startTransition, useLayoutEffect, useRef, useState } from "react";
          
          const screens = [];
          
          export default function Score() {
            const [score, setScore] = useState(3);
            const line = useRef(null);
          
            useLayoutEffect(() => {
              if (screens.at(-1) !== score) screens.push(score);
              line.current.textContent = "Screens so far: " + screens.join(", ");
            });
          
            function bonus() {
              startTransition(() => setScore((s) => s + 5));
              setScore((s) => s * 2);
            }
          
            return (
              <p>
                <button onClick={bonus}>Bonus</button> <span ref={line} />
              </p>
            );
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFind the bug in the profile

            Click Move to Mumbai. What does the page say, and why? Then fix moveToMumbai.

            import { useState } from "react";
            
            export default function Profile() {
              const [profile, setProfile] = useState({ name: "Asha", city: "Pune" });
            
              function moveToMumbai() {
                profile.city = "Mumbai";
                setProfile(profile);
              }
            
              return (
                <p>
                  {profile.name} lives in {profile.city}. <button onClick={moveToMumbai}>Move to Mumbai</button>
                </p>
              );
            }

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask where the update is at that moment: in your set call, waiting, in the ring, or being applied.

              The setState internals quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In React's development build, what is setCount.name?

                Choose an answer to see the explanation.

              2. Question 2 of 7Right after setCount(1) in a click handler, before React renders, where is the update object?

                Choose an answer to see the explanation.

              3. Question 3 of 7Three set calls queued three updates, and queue.pending holds the update for the last one. What is queue.pending.next?

                Read the code, then predictJavaScript
                queue.pending       // the update for setCount(3)
                queue.pending.next  // ?

                Choose an answer to see the explanation.

              4. Question 4 of 7Count starts at 0, and a click runs this handler. What does the next render show?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function handleClick() {
                  setCount((c) => c + 1);
                  setCount(5);
                  setCount((c) => c * 2);
                }

                Choose an answer to see the explanation.

              5. Question 5 of 7When does dispatchSetState skip the eager state?

                Choose an answer to see the explanation.

              6. Question 6 of 7A handler queues s => s + 1 inside startTransition, then s => s * 10 outside it. The score starts at 1. What do the screens show?

                Choose an answer to see the explanation.

              7. Question 7 of 7Why does a click with three set calls render once?

                Choose an answer to see the explanation.

              Key takeaways

              • A set function is dispatchSetState bound to one fiber and one hook's queue. It's the same function on every render.
              • Each call makes an update object (lane, action, hasEagerState, eagerState, next), which waits in concurrentQueues and joins the hook's ring when the next render starts. queue.pending points at the newest; its next is the oldest.
              • When nothing is waiting, React works out the next state inside your set call, and stops if it's the same. Otherwise the render decides.
              • scheduleUpdateOnFiber marks the root, and one microtask renders every update queued before it: that's batching.
              • updateReducerImpl applies the queue in order. Updates whose lane the render doesn't include are skipped, kept, and rebased by a later render.

              Remember the one-liner.
              A set call queues an update and asks for a render; the render applies the queue, in order, lane by lane.

              Coming next: Lanes: how React prioritizes updates. It opens ReactFiberLane.js and shows how React picks which lanes to render next, and how it ties lanes together.

              CompleteFrontend Clear concepts. Working examples.