Why setState Never Re-renders on the Spot: React's Update Scheduling
Follow a React state update from the queued update object through lane marking and scheduling to the root-first work loop that skips unaffected subtrees.
This article is published in English.
Sources: The Fiber Architecture In React Part 3: What Actually Happens When You Call setState
Almost every React developer carries a quiet assumption: calling a state setter such as setCount(count + 1) re-runs the component right there, before the next line executes. It does not, and the gap between the call and the re-render explains batching, why unrelated components stay untouched, and why reading state immediately after setting it returns the old value. This article follows a single update through React's fiber architecture, from the moment it is recorded to the moment the new value lands in the component's state, so you can reason about what your setters actually do.
The component under the microscope
A minimal counter is enough to expose the whole mechanism. It holds one piece of state and increments it on click.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
The call setCount(count + 1) reads like an ordinary synchronous function, so it is natural to picture React invoking Counter() again as a direct consequence of that line. Nothing of the sort happens. The setter returns without rendering anything. What it does instead is record a request and ask for work to be done later.
Step one: the setter records an update
When setCount(1) runs, React allocates a small update object in memory that describes the request. Conceptually it looks like this:
{
action: 1,
next: null,
}
The action field holds whatever you passed in, here the value 1. The next field exists because one update is rarely the whole story. Call the setter three times inside a single handler and React creates three update objects linked together through next, forming a small list attached to the component. In React's real source the update also carries a lane (its priority) and the pending list is stored as a circular structure, but the essential idea is a queue of intentions, not a re-render.
In a simplified model of the fiber, the update is attached like this:
{
type: Counter,
memoizedState: { count: 0 }, // still 0, untouched
updateQueue: { action: 1, next: null }, // the note, just added
}
Notice what has not changed. The stored state still reads 0, the screen still shows 0, and Counter() has not been called. The only difference is that an object now waits in a queue. (For precision: with hooks, React keeps each hook's queue on that hook's entry in the fiber's hook list rather than directly in a field called updateQueue, and memoizedState on a function component fiber is that hook list. The flattened model above is a teaching simplification, and it preserves the behavior that matters here.)
This also explains a classic surprise. If you log count right after calling setCount, you see the old value, because the component function that closed over count has not run again yet.
Step two: scheduleUpdateOnFiber marks a trail and requests work
Right after the update is queued, React calls an internal function named scheduleUpdateOnFiber. It has two separate responsibilities, and keeping them apart makes the rest of the process easier to follow.
Marking the path to the root
First, React walks upward from the Counter fiber through each parent pointer until it reaches the root of the tree. On every ancestor along the way it sets a field called childLanes, which effectively says "something below me has pending work". The Counter fiber itself gets a related field, lanes, which means "I have pending work of my own".
These fields are not simple booleans. They are bitmasks that encode priority, which is a topic of its own; see how React lanes use bitmasks to encode update priority. For this walkthrough it is enough to know that they go from empty to non-empty, leaving a breadcrumb trail from the updated component to the top.
Deferring the actual rendering
Second, React asks for the rendering work to run shortly, not inside your event handler. The exact mechanism depends on priority. Work handled by React's scheduler is typically posted with MessageChannel (with setTimeout as a fallback in environments that lack it), so it runs on a later turn of the event loop. For discrete, high-priority events such as clicks, modern React versions usually flush the work in a microtask right after the handler finishes, which is still after your code, just sooner. The exact strategy has shifted between releases, so treat the details as version-specific.
Whatever the channel, the consequence is the same: rendering is deliberately deferred until your handler has returned. That is what makes batching possible. Three calls to setCount in one handler produce three queued updates, and a single later render pass processes all of them together instead of rendering three times.
Step three: the render always begins at the root
When the scheduled work finally runs, it does not start at Counter. It starts at the root fiber, the special node above your whole application that was created when you called ReactDOM.createRoot. This happens regardless of how deep in the tree the update originated.
Starting at the root sounds expensive, but it does not mean visiting every fiber. The trail of childLanes set in step two is exactly what the walk consults. At each fiber, React checks whether the subtree below has any pending work. If it has none, React bails out and skips the entire subtree without descending into it. Ancestors on the marked path are passed through cheaply, and only the branches that lead to pending work are explored.
A loop, not recursion
The traversal is implemented as a loop rather than deep recursion:
let node = rootFiber;
while (node !== null) {
node = processNode(node); // moves to node.child, node.sibling, or up via node.return
}
Every fiber holds three pointers: child for its first child, sibling for the next fiber at the same level, and return for its parent. Those three links are enough to move down into children, across to siblings and back up when a branch is finished, one step at a time. There is no array of children to iterate. Because the position in the tree is held in a variable rather than on the call stack, React can pause between units of work and resume later, which is the foundation of concurrent rendering.
Why unrelated components stay untouched
This is the mechanism behind an effect you have probably observed. Typing into an input updates only the part of the tree that owns that state. A distant, unrelated component is not re-rendered and is not even visited, because no update marked its branch. The skipping is not a special optimization bolted on afterwards. It falls directly out of following the childLanes trail.
Keep the limits of this in mind. A component on the marked path that itself has pending work will re-render, and by default so will all of its descendants, marked or not, unless they are memoized. The trail tells React where to go; it does not stop a re-rendering parent from re-rendering its children.
Step four: arriving at Counter and consuming the queue
When the walk reaches Counter, the double-buffering mechanism takes over. React checks the alternate pointer on the current fiber and either reuses the existing work-in-progress fiber or creates a new one. That work-in-progress copy is where the new state is computed.
Now the queue is finally read. React processes the pending update, applies the action to the previous state, and writes the result into the work-in-progress fiber's memoizedState. During this step Counter() is actually called, and useState returns the new value. If several updates were queued, they are applied in order; functional updates such as setCount(c => c + 1) each receive the result of the previous one, which is why they behave correctly when batched while setCount(count + 1) three times yields only one increment.
// work-in-progress fiber, right after processing the queue
{
type: Counter,
memoizedState: { count: 1 }, // finally updated, using the queued action
updateQueue: null, // consumed, nothing left pending
alternate: Fiber A,
}
The queue is now empty and the new state lives on the work-in-progress fiber. Three separate mechanisms have met: the queued update from step one, the marked trail and root-first walk from steps two and three, and the current/work-in-progress fiber pair that holds the before and after versions. For a broader trace that continues through reconciliation and the DOM commit, see tracing a React setState call from update queue to DOM commit.
The complete sequence
Laid out end to end, a single click goes through these stages:
- The click handler calls
setCount, and React creates an update object and adds it to the component's queue. scheduleUpdateOnFibersetslanesonCounterandchildLaneson every ancestor up to the root.- The same function requests a render pass that runs after the handler returns.
- The render pass starts at the root fiber and moves through
child,siblingandreturn, skipping subtrees with no pending lanes. - At
Counter, React prepares the work-in-progress fiber, processes the queued updates and computes the new state. - The commit phase applies the DOM changes, and the work-in-progress tree becomes the new current tree.
None of this happens on the line where you called the setter. Everything runs afterwards, in a structure whose fields exist specifically to support this deferred, resumable process.
Key takeaways
- A state setter enqueues an update and schedules work; it never renders synchronously inside your handler.
- Reading state right after setting it returns the old value because the component has not re-run yet.
- Batching is a natural result of deferral: every update queued before the render pass is processed in one go.
lanesandchildLanesmark the path from the updated fiber to the root, letting React skip untouched subtrees.- The render loop uses
child,siblingandreturnpointers instead of recursion, which is what allows React to pause and resume work. - A re-rendering component still re-renders its children by default; the lane trail limits where React looks, not what a parent renders.