setState: dispatchSetState and Batching
setCount(x) is dispatchSetState(fiber, queue, x). It picks a lane from the current event priority or transition, and may compute the new state eagerly and drop the update when…
- Published
- Reading time
- 10 min read
Previous pages built the hook and its queue (Hooks on Mount) and showed how the queue is consumed on re-render (Hooks on Update). This page covers the producer side: everything between your onClick calling setCount and the render starting.
1. The call path at a glance
flowchart TD
A["setCount(c => c + 1)<br/>= dispatchSetState.bind(null, fiber, queue)"] --> B[requestUpdateLane]
B --> C[dispatchSetStateInternal]
C -->|render-phase update| R[enqueueRenderPhaseUpdate → re-run component]
C -->|fiber has no pending lanes| D{"eager: reducer(lastRenderedState, action)<br/>Object.is equal?"}
D -->|yes| E[enqueueConcurrentHookUpdateAndEagerlyBailout<br/>NO render scheduled]
D -->|no| F
C -->|otherwise| F[enqueueConcurrentHookUpdate<br/>buffer in concurrentQueues, mark fiber.lanes]
F --> G[scheduleUpdateOnFiber → markRootUpdated]
G --> H[ensureRootIsScheduled<br/>root into schedule list]
H --> I[ensureScheduleIsScheduled<br/>queueMicrotask once]
I --> J[processRootScheduleInMicrotask]
J -->|SyncLane| K[flushSyncWorkAcrossRoots → performSyncWorkOnRoot]
J -->|other lanes| L[Scheduler task: performWorkOnRootViaSchedulerTask]2. requestUpdateLane: what priority is this update?
export function requestUpdateLane(fiber) {
if (!disableLegacyMode && (fiber.mode & ConcurrentMode) === NoMode) return SyncLane; // legacy root
if ((executionContext & RenderContext) !== NoContext && workInProgressRootRenderLanes !== NoLanes) {
return pickArbitraryLane(workInProgressRootRenderLanes); // setState during render
}
const transition = requestCurrentTransition(); // ReactSharedInternals.T
if (transition !== null) return requestTransitionLane(transition);
return eventPriorityToLane(resolveUpdatePriority());
}(simplified)1
disableLegacyMode is true in 19.x OSS builds, so the first branch is dead code there.2 The interesting inputs:
- Transition:
startTransitionsetsReactSharedInternals.Tto a transition object for the duration of the callback.34 All transition updates in the same event share one lane:requestTransitionLanecaches it incurrentEventTransitionLaneuntil the microtask clears it. That way they render together.5 - Event priority:
resolveUpdatePriority()in react-dom returnsReactDOMSharedInternals.pif it's set. React's own event listeners set it toDiscreteEventPriorityfor clicks and keydowns (see Event System). If it isn't set, it falls back togetEventPriority(window.event.type). If there's nowindow.event(asetTimeout, a fetch callback), it'sDefaultEventPriority.6 - Event priority = lane:
eventPriorityToLaneis the identity function.DiscreteEventPriorityis defined asSyncLane,ContinuousEventPriorityasInputContinuousLane,DefaultEventPriorityasDefaultLane.7
3. dispatchSetStateInternal: the eager bailout
function dispatchSetStateInternal(fiber, queue, action, lane) {
const update = { lane, revertLane: NoLane, gesture: null, action,
hasEagerState: false, eagerState: null, next: null };
if (isRenderPhaseUpdate(fiber)) { // setState while this component renders
enqueueRenderPhaseUpdate(queue, update);
} else {
const alternate = fiber.alternate;
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
// queue is empty, so the next state depends only on this action
const currentState = queue.lastRenderedState;
const eagerState = queue.lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false; // no render scheduled at all
}
}
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
return true;
}
}
return false;
}(simplified, try/catch and DEV dispatcher swap dropped)89
Why the precondition fiber.lanes === NoLanes on both twins? Eager computation is only valid if no other updates are queued ahead of this one. With pending updates, the real next state is reduce(reduce(base, u1), thisAction), and lastRenderedState would be the wrong base. Since bind captured one fiber of the current/alternate pair, both have to be checked.
Consequences that surprise people:
- The bailout still enqueues the update (with
NoLane), so it can be rebased correctly if a render is already in progress. It just doesn't schedule anything.10 - The second call in a handler never gets the eager path. The first
setXmarksfiber.lanes, sosetX(1); setX(1)from0schedules a render and then bails out late (after calling your component). See Hooks on Update section 5. - The eager path is less reliable than it looks, even across separate events. When a render commits, the work-in-progress copy of the fiber has its lanes cleared, but the other copy (now the alternate) can keep the stale
SyncLane. The next same-valuesetStatethen fails the "both twins haveNoLanes" check and does one late-bailout render first. That's the well-known "setting the same state renders one extra time" behaviour. Traced step by step in setState Walkthrough. - If the eager reducer throws, the error is swallowed and the update is queued normally. It'll throw again during render, where error boundaries can catch it.8
- The eager result is reused during render (
hasEagerState), so a functional updater normally runs once, not twice. In StrictMode DEV it's re-invoked on purpose.
A render-phase update (calling setState in the component body) takes enqueueRenderPhaseUpdate. That function pushes straight onto queue.pending and sets didScheduleRenderPhaseUpdateDuringThisPass. renderWithHooks then re-runs the component immediately, up to 25 times before throwing "Too many re-renders".1112
4. Enqueueing: buffered, not linked
function enqueueUpdate(fiber, queue, update, lane) {
concurrentQueues[concurrentQueuesIndex++] = fiber;
concurrentQueues[concurrentQueuesIndex++] = queue;
concurrentQueues[concurrentQueuesIndex++] = update;
concurrentQueues[concurrentQueuesIndex++] = lane;
concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
fiber.lanes = mergeLanes(fiber.lanes, lane); // mark source fiber right away
if (fiber.alternate !== null) fiber.alternate.lanes = mergeLanes(fiber.alternate.lanes, lane);
}Updates go into a flat module-level array, not onto queue.pending. Why? A concurrent render may be running (yielded mid-tree), and updateReducerImpl reads queue.pending. If a new update appeared in the middle of a render, two components reading the same state could see different values: tearing. Buffering keeps the render's view stable. finishQueueingConcurrentUpdates() drains the buffer into each queue.pending ring and calls markUpdateLaneFromFiberToRoot.15 It runs at the start of a fresh render (prepareFreshStack)16 and at a few other safe points. enqueueConcurrentHookUpdate returns the root by walking .return to the HostRoot, so it can schedule immediately.17
markUpdateLaneFromFiberToRoot: why childLanes matter
sourceFiber.lanes |= lane; alternate.lanes |= lane;
let parent = sourceFiber.return;
while (parent !== null) {
parent.childLanes = mergeLanes(parent.childLanes, lane);
if (parent.alternate !== null) parent.alternate.childLanes = mergeLanes(parent.alternate.childLanes, lane);
// (track whether we pass through a hidden Offscreen)
parent = parent.return;
}(simplified)18
This is what makes the render phase cheap. Every ancestor learns that somewhere below it there's work in lane X. When the work loop starts from the root, beginWork on an ancestor with unchanged props and no own lanes calls bailoutOnAlreadyFinishedWork. That checks includesSomeLane(renderLanes, workInProgress.childLanes). If it's false, the whole subtree is skipped in O(1). If it's true, React clones the children and keeps descending, but only along the marked path.19 Without childLanes, React would have to visit every fiber to find the updated one. Updating both twins matters because which twin becomes the WIP depends on the commit history.
5. scheduleUpdateOnFiber → ensureRootIsScheduled → microtask
scheduleUpdateOnFiber calls markRootUpdated(root, lane), which adds the lane to root.pendingLanes. It handles some interleaving cases (an update arriving during render goes to workInProgressRootInterleavedUpdatedLanes), then calls ensureRootIsScheduled(root).20 That function is tiny:
export function ensureRootIsScheduled(root) {
// add root to a linked list of scheduled roots (if not already there)
mightHavePendingSyncWork = true;
ensureScheduleIsScheduled();
}
export function ensureScheduleIsScheduled() {
if (!didScheduleMicrotask) {
didScheduleMicrotask = true;
scheduleImmediateRootScheduleTask(); // queueMicrotask(processRootScheduleInMicrotask)
}
}scheduleMicrotask is queueMicrotask in the DOM host config.24 When the microtask runs, processRootScheduleInMicrotask resets the flag. For each root, scheduleTaskForRootDuringMicrotask computes getNextLanes. If the result includes SyncLane, it cancels any Scheduler task and leaves callbackNode = null. Otherwise it schedules performWorkOnRootViaSchedulerTask with the Scheduler, unless a callback of the same priority already exists, in which case it reuses it.2526 Finally flushSyncWorkAcrossRoots_impl renders all roots that have sync lanes, synchronously, still inside the microtask, via performSyncWorkOnRoot.2728
6. Automatic batching is a side effect of this design
There's no "batch" object. Batching falls out of three dedup points:
didScheduleMicrotask: however manysetStatecalls happen in the current task, only one microtask is queued.- The root is in the schedule list once (
root === lastScheduledRoot || root.next !== null). root.callbackPriority: a second update of the same priority reuses the existing Scheduler callback.
All updates land in queues and pendingLanes before the microtask runs. The single render that follows then processes all of them together. Because this lives at the root-scheduling level and doesn't depend on being inside a React event handler, React 18+ batches setTimeout, promise, and native-listener updates too. React 17 only batched inside React event handlers (via batchedUpdates), and each setState in a setTimeout caused its own synchronous render.29 In 19.x OSS, the reconciler's batchedUpdates is literally return fn(a) because legacy mode is disabled.30
function handleClick() {
setCount(c => c + 1); // SyncLane, queued; microtask scheduled
setFlag(f => !f); // SyncLane, queued; microtask already scheduled
setCount(c => c + 1); // queued
} // → after the handler returns, the microtask runs ONE render7. flushSync and startTransition: overriding the defaults
flushSync(fn) sets ReactSharedInternals.T = null and the update priority p = DiscreteEventPriority, runs fn, restores both, and then calls flushSyncWork(), which runs flushSyncWorkOnAllRoots().3132 Updates inside get SyncLane and are rendered and committed before flushSync returns, without waiting for the microtask. Use it when you need the DOM updated before your next line runs (measuring, focusing, third-party integration). It's a de-optimisation otherwise. Calling it during render or commit logs "flushSync was called from inside a lifecycle method".
startTransition(fn) creates a transition object, puts it on ReactSharedInternals.T, runs fn synchronously, and restores the previous value.3 Only setState calls made synchronously inside fn see the transition, because requestUpdateLane reads T at call time. Since React 19, fn may be async (an Action). The transition stays pending until the returned promise settles, and updates in the same action scope share one lane (peekEntangledActionLane in requestTransitionLane).5 But T is restored as soon as fn returns synchronously, so a setState after an await is no longer inside the transition. Wrap it in another startTransition.
8. Misconceptions
With the React Compiler
Unchanged. dispatchSetState, lanes, queues and root scheduling are runtime reconciler code, and the compiler only transforms component and hook bodies. The compiler does affect what happens after the render this page schedules. With memoized JSX and props, more children take the early oldProps === newProps bailout, so a setState re-renders less of the subtree. Setters are already stable, so the compiler treats them as non-reactive (isStableType/isSetStateType) and doesn't put them in cache dependency checks.34 See With vs Without Compiler.
Summary
Between your handler calling setCount and the render starting, dispatchSetState picks a lane, may compute the next state eagerly and skip scheduling, and otherwise buffers the update, marks lanes up to the root, and asks for the root to be scheduled. Scheduling is deduplicated to one microtask per tick and one callback per root per priority, so batching is a side effect of the design rather than a feature. Keep this model: setState is not async, it is deferred and batched, and your closure's count is from the render that created it. The consumer side is in Hooks on Update.
Key takeaways
requestUpdateLane: reads the transition onReactSharedInternals.T, else the event priority, whereDiscreteEventPriorityisSyncLane.- Eager bailout: applies only when both twins have
NoLanes; it still enqueues the update, and later same-value calls bail out late, after your component runs. concurrentQueues: updates go into a flat buffer, notqueue.pending, so a yielded concurrent render never sees state change mid-render.markUpdateLaneFromFiberToRoot: ORs the lane into every ancestor'schildLanes, lettingbeginWorkskip clean subtrees.ensureRootIsScheduled: queuesprocessRootScheduleInMicrotaskonce;SyncLanework renders inside the microtask, other lanes get a Scheduler task.- Automatic batching: three dedup points batch updates from timeouts, promises and native listeners too, and different lanes still mean different renders.
flushSync: forcesSyncLaneand renders and commits before returning; a de-optimisation unless you need the DOM now.startTransition: onlysetStatecalls made synchronously insidefnsee the transition, so wrap updates after anawaitagain.
Footnotes
-
requestUpdateLane— packages/react-reconciler/src/ReactFiberWorkLoop.js#L812-L868 (opens in a new tab) ↩ -
disableLegacyMode = true— packages/shared/ReactFeatureFlags.js#L205 (opens in a new tab) ↩ -
startTransition— packages/react/src/ReactStartTransition.js#L45-L118 (opens in a new tab) ↩ ↩2 -
requestCurrentTransition— packages/react-reconciler/src/ReactFiberTransition.js#L193-L195 (opens in a new tab) ↩ -
requestTransitionLane(per-event shared lane) — packages/react-reconciler/src/ReactFiberRootScheduler.js#L698-L724 (opens in a new tab) ↩ ↩2 -
resolveUpdatePriority— packages/react-dom-bindings/src/client/ReactDOMUpdatePriority.js#L35-L45 (opens in a new tab) ↩ -
Event priorities and
eventPriorityToLane— packages/react-reconciler/src/ReactEventPriorities.js#L24-L53 (opens in a new tab) ↩ -
dispatchSetStateInternal— packages/react-reconciler/src/ReactFiberHooks.js#L3656-L3726 (opens in a new tab) ↩ ↩2 -
dispatchSetState— packages/react-reconciler/src/ReactFiberHooks.js#L3626-L3654 (opens in a new tab) ↩ -
enqueueConcurrentHookUpdateAndEagerlyBailout— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L127-L151 (opens in a new tab) ↩ -
isRenderPhaseUpdate/enqueueRenderPhaseUpdate— packages/react-reconciler/src/ReactFiberHooks.js#L3838-L3864 (opens in a new tab) ↩ -
renderWithHooksAgain(RE_RENDER_LIMIT) — packages/react-reconciler/src/ReactFiberHooks.js#L791-L857 (opens in a new tab) ↩ -
enqueueUpdate(concurrentQueues buffer) — packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L90-L113 (opens in a new tab) ↩ -
enqueueConcurrentHookUpdate— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L115-L125 (opens in a new tab) ↩ -
finishQueueingConcurrentUpdates— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L50-L84 (opens in a new tab) ↩ -
prepareFreshStackcallsfinishQueueingConcurrentUpdates— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2284 (opens in a new tab) ↩ -
getRootForUpdatedFiber— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L252-L276 (opens in a new tab) ↩ -
markUpdateLaneFromFiberToRoot— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L189-L250 (opens in a new tab) ↩ -
bailoutOnAlreadyFinishedWork(childLanes check) — packages/react-reconciler/src/ReactFiberBeginWork.js#L3826-L3865 (opens in a new tab) ↩ -
scheduleUpdateOnFiber— packages/react-reconciler/src/ReactFiberWorkLoop.js#L987-L1113 (opens in a new tab) ↩ -
ensureRootIsScheduled— packages/react-reconciler/src/ReactFiberRootScheduler.js#L116-L152 (opens in a new tab) ↩ -
ensureScheduleIsScheduled— packages/react-reconciler/src/ReactFiberRootScheduler.js#L154-L169 (opens in a new tab) ↩ -
scheduleImmediateRootScheduleTask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L650-L696 (opens in a new tab) ↩ -
scheduleMicrotask=queueMicrotask— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L871-L877 (opens in a new tab) ↩ -
processRootScheduleInMicrotask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L259-L348 (opens in a new tab) ↩ -
scheduleTaskForRootDuringMicrotask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L384-L509 (opens in a new tab) ↩ -
flushSyncWorkAcrossRoots_impl— packages/react-reconciler/src/ReactFiberRootScheduler.js#L185-L247 (opens in a new tab) ↩ -
performSyncWorkOnRoot— packages/react-reconciler/src/ReactFiberRootScheduler.js#L608-L622 (opens in a new tab) ↩ -
"Automatic batching for fewer renders in React 18", reactwg/react-18 Discussion #21 — https://github.com/reactwg/react-18/discussions/21 (opens in a new tab) ↩
-
batchedUpdates(no-op whendisableLegacyMode) — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1854-L1878 (opens in a new tab) ↩ -
flushSyncImpl— packages/react-dom/src/shared/ReactDOMFlushSync.js#L19-L50 (opens in a new tab) ↩ -
DOM
flushSyncWork→flushSyncWorkOnAllRoots— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L5222-L5234 (opens in a new tab) ↩ -
"Queueing a Series of State Updates" — react.dev, https://react.dev/learn/queueing-a-series-of-state-updates (opens in a new tab) ↩
-
isSetStateType/isStableType— compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts#L1850-L1909 (opens in a new tab) ↩