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.
- 01Follow 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.
- 02Read the update queueExplain the ring queue.pending points at, why updates wait in an array first, and why many set calls make one render.
- 03Work 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:
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> );}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:
- After the handler: the fiber's
lanesis 2 and so isroot.pendingLanes, butqueue.pendingis stillnull. React knows work is waiting, but the updates aren't in the queue yet. - Three Ring lines: when the render starts, React links the three update objects into a ring, one at a time. Each has
lane 2and your updater as itsaction. The first also haseagerState 1. - A render took the ring: the render moved the updates out of
queue.pendingto work through them. The button now saysAdd 3 teas (3).
The rest of this lesson explains each line. Here is the whole path first:
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.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
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:
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];
}- Line 4 makes the hook and its queue. Line 5 takes the queue.
- Lines 6 to 10 make the set function:
dispatchSetState.bind(null, currentlyRenderingFiber, queue).bindmakes 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 callsetCount(5), React getsdispatchSetState(fiber, queue, 5). - Line 11 keeps it in
queue.dispatch, and line 12 returns it next to the state. Later renders returnqueue.dispatchagain, so it's the same function every time.
See it on a live set function. Click Render again:
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> </> );}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.
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:
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:
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.
| Field | What it holds | In the Add 3 teas click |
|---|---|---|
lane | How urgent the update is, as one bit: 2 for a click, 32 for a timer, 256 or another transition bit for a transition. | 2 |
revertLane | Only for useOptimistic: the lane whose end removes the optimistic value. A plain set call leaves it at NoLane, 0. | 0 |
action | What you passed to the set function: a value, or an updater function. | (c) => c + 1 |
hasEagerState, eagerState | Whether 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. |
next | The next update in the ring. | The next update; the last one points back at the first. |
gesture | For 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:
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:
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> );}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:
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:
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);
}
}- Lines 9 to 12 push four things into an array,
concurrentQueues: the fiber, the queue, the update, and the lane. The update is not inqueue.pendingyet. 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. - Lines 19 to 23 mark the fiber's
lanesstraight away, on both copies of the fiber. Lines 16 and 17 give the reason: the eager bailout checks this field. That's thefiber.lanes 2the 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:
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;
}- Line 2 reads
queue.pending. If it'snull, line 5 points the update at itself: a ring of one. - Otherwise
pendingis the newest update, andpending.nextthe oldest. Line 7 points the new update at the oldest, and line 8 points the old newest at the new one. - Line 10 makes the new update the newest:
queue.pendingnow points at it.
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.
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 {
// …
}
}
}- Lines 2 to 5: only if
fiber.lanesand the other copy'slanesare both empty. Then nothing is waiting for this component, so the next state depends only on this update. - Lines 13 and 14 take the state of the last render and call
lastRenderedReducerwith your action, right now, inside your set call. - Lines 16 and 17 keep the result on the update:
hasEagerStateandeagerState. - 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. - 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:
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:
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> </> );}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:
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 /> </> );}- At first,
Counterwas called 2 times and its effect ran 2 times: StrictMode calls components and runs effects twice when they mount, in development. - 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.Counterwasn't even called. - Add one: called 4 times, effect 3 times. A normal render.
- 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,
useStateworked out the same state, so React stopped there: it didn't renderCounter's children (the reconciliation lesson covers these bailouts), and the effect didn't run. This render also cleared the old lane. - 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 you call the set function | Does React try the eager state? | What happens next |
|---|---|---|
| The first set call, when neither copy of the fiber has a pending lane | Yes. | 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 handler | No: 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 lane | No. | React renders. If the state comes out the same, it stops after calling the component, before its children. |
Any call of useReducer's dispatch | Never: 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:
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:
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:
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;
}
// …
}- Lines 7 to 11 mark the fiber itself, on both copies, again.
- 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).
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.
| Field | Who marks it | When |
|---|---|---|
fiber.lanes, on both copies of the fiber | enqueueUpdate, in ReactFiberConcurrentUpdates.js | Right away, inside your set call. |
root.pendingLanes | markRootUpdated, called by scheduleUpdateOnFiber | Right away, inside your set call. |
The parents' childLanes, up to the root | markUpdateLaneFromFiberToRoot | When the next render starts, in finishQueueingConcurrentUpdates. |
queue.pending, the ring | finishQueueingConcurrentUpdates | When 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:
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:
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:
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> </> );}The end of the handler sees: Add 3 teas (0). Line 15 runs after all three set calls, and nothing has rendered.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.A microtask queued after setCount sees: Add 3 teas (3). Line 11'ssetCountqueued 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:
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;
}- Line 1:
hook.baseQueueholds updates an earlier render skipped. It's usuallynull. - Lines 8 to 14: if both exist, React joins the two rings into one, old updates first, by swapping two
nextpointers. - Lines 16 and 17: the joined ring becomes the base queue, and
queue.pendinggoes back tonull. 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.
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);- Lines 1 to 8: start at the oldest update,
baseQueue.next, withnewStateset to the base state. - 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. - 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. - 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). - 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:
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:
// count starts at 0
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);renderLanes 2| Update | lane | eagerState | This render |
|---|---|---|---|
(c) => c + 1 | 2 | 1 | waiting |
(c) => c + 1 | 2 | none | waiting |
(c) => c + 1 | 2 | none | waiting |
- newState
- 0
- newBaseState
- null
New base queue: updates kept for a later render
Empty: nothing was skipped.
React starts the click's render (SyncLane, 2). It takes the ring from queue.pending: 3 updates, starting from baseState 0.
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.
Here is the same handler in real React. Click Bonus:
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> );}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:
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> </> );}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):
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> </> );}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:
- 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
Sort each step of a set call into the part of React that does it.
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
concurrentQueuesand 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.
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>
</>
);
}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> </> );}Line 15 shows addItem.name: bound dispatchReducerAction. mountReducer makes it with dispatchReducerAction.bind(null, currentlyRenderingFiber, queue), and a bound function's name starts with bound.
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>
);
}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> );}The ring shows lane 32. The update runs in a timer, outside any event, so requestUpdateLane falls back to the default priority, DefaultLane, the sixth bit: 32.
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>
);
}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> );}The first update shows eagerState 5: the queue was empty, so React worked out basicStateReducer(0, 5) right away. The second call found fiber.lanes set, so it has none. The render uses 5, then calls (c) => c * 2: the button says Refill (10).
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 />
</>
);
}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 /> </> );}It says CupSize was called 4 times. useReducer's dispatch never tries the eager state, so React renders, calls CupSize (twice, in StrictMode), sees the same state, and stops before its children render. Every click adds 2.
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>
);
}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> );}Screens so far: 3, 6, 16. The click's render skips the transition's update and shows 3 × 2 = 6. The transition's render starts from the base state, 3, and applies both in order: 3 + 5 = 8, then 8 × 2 = 16.
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>
);
}import { useState } from "react"; export default function Profile() { const [profile, setProfile] = useState({ name: "Asha", city: "Pune" }); function moveToMumbai() { setProfile({ ...profile, city: "Mumbai" }); } return ( <p> {profile.name} lives in {profile.city}. <button onClick={moveToMumbai}>Move to Mumbai</button> </p> );}Before the fix, the page still says Asha lives in Pune.: line 7 changed the object in place, and line 8 passed the same object, so React compared it with itself and stopped. The fix passes a new object, { ...profile, city: "Mumbai" }, and the page says Asha lives in Mumbai.
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.
Question 1 of 7In React's development build, what is
setCount.name?Choose an answer to see the explanation.
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.
Question 3 of 7Three set calls queued three updates, and
queue.pendingholds the update for the last one. What isqueue.pending.next?Read the code, then predictJavaScriptqueue.pending // the update for setCount(3) queue.pending.next // ?Choose an answer to see the explanation.
Question 4 of 7Count starts at 0, and a click runs this handler. What does the next render show?
Read the code, then predictfunction handleClick() { setCount((c) => c + 1); setCount(5); setCount((c) => c * 2); }Choose an answer to see the explanation.
Question 5 of 7When does
dispatchSetStateskip the eager state?Choose an answer to see the explanation.
Question 6 of 7A handler queues
s => s + 1insidestartTransition, thens => s * 10outside it. The score starts at 1. What do the screens show?Choose an answer to see the explanation.
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
dispatchSetStatebound 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 inconcurrentQueuesand joins the hook's ring when the next render starts.queue.pendingpoints at the newest; itsnextis 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.
scheduleUpdateOnFibermarks the root, and one microtask renders every update queued before it: that's batching.updateReducerImplapplies 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.