Home / Articles / Tracing a React setState Call From Update Queue to DOM Commit

This article is published in English.

Tracing a React setState Call From Update Queue to DOM Commit

Follow a React state update step by step through the Hook update queue, scheduler, render phase, reconciliation and commit, and see why state never changes immediately.

2432 words

Almost every React developer eventually writes a state setter followed by a console.log and is surprised to see the old value printed. The name setState suggests an immediate assignment, but that is not what React does: calling a setter records a request for a future state, and a whole pipeline runs before anything reaches the screen. This article follows a single update through that pipeline, from the Hook's update queue through scheduling, rendering, reconciliation and the commit phase. Once you can picture each stage, several behaviors that seem odd at first become predictable: why state looks asynchronous, why several updates can collapse into one render, why React sometimes skips work, and why functional updates exist.

The snippet that surprises everyone

Imagine a code review where this looks perfectly reasonable:

setCount(count + 1);

console.log(count);

The expectation is that the console shows the incremented value. It shows the previous one. Here is the same situation inside a full component:

function Counter() {
    const [count, setCount] = useState(0);

    const handleClick = () => {
        setCount(count + 1);
        console.log(count);
    };

    return (
        <button onClick={handleClick}>
            {count}
        </button>
    );
}

On the first click, many developers expect to see:

1

What actually appears in the console is:

0

The reason is that React has not rendered again yet. At the moment console.log runs, React has only been handed a request. Nothing in the queue has been applied yet, the component function has not been called again, and the DOM has not changed. The code you are executing belongs to the current render, and inside that render count is a plain constant that was fixed when the function ran. Nothing can reassign it.

That is the first shift in mental model: a setter does not mutate the state variable you are reading. It schedules work for React to do later.

What React stores when you call a setter

When you write:

setCount(count + 1);

it is tempting to imagine React doing something like this internally:

count = count + 1

It does not. React creates an update object and appends it to a queue that belongs to that specific Hook. Conceptually, the situation after the click looks like this:

Current State
      |
      ▼
count = 0
      |
      ▼
User Clicks
      |
      ▼
setCount(1)
      |
      ▼
Update Queue
[ Update: 1 ]

The state itself is untouched. All React has done is write itself a note: the next time updates are processed for this Hook, count should become 1.

Why updates go through a queue

A queue makes sense as soon as several updates arrive close together. Consider a handler that calls the setter three times:

const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
};

Someone new to React might expect a single click to produce:

count = 3

The real result is:

count = 1

All three calls were made during the same render, and that render saw:

count = 0

So each count + 1 evaluates to the same number, and React receives three identical requests:

setCount(1)
setCount(1)
setCount(1)

The queue therefore holds three entries that all say "set to 1":

[1]
[1]
[1]

Processing them in order still ends with:

1

and not:

3

The same logic applies when the values differ. Suppose a render issues these three calls:

setCount(1)
setCount(6)
setCount(4)

The queue then contains:

[1]
[6]
[4]

Each entry replaces the previous state outright, so after processing, only the last one matters and the result is 4. Plain values are replacements, not instructions to build on what came before. That limitation is exactly why functional updates exist.

Functional updates compute from the latest state

Now change the handler so each call passes a function:

const handleClick = () => {
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
};

This time the queue holds something closer to three small programs rather than three values:

prev => prev + 1
prev => prev + 1
prev => prev + 1

When React processes the queue, it feeds the output of each function into the next one:

0 → 1
1 → 2
2 → 3

and the final state is:

count = 3

The difference is that an updater function does not capture a value from the current render. It describes how to derive the next state from whatever state React has at the moment it processes that entry. Use this form whenever the new state depends on the previous one, especially when several updates may be queued together or when the update happens inside a callback created during an older render.

The full lifecycle of one update

With the queue in mind, follow a single click that calls:

setCount(count + 1);

A simplified view of everything that happens next:

User Click
     |
     ▼
Create Update
     |
     ▼
Place Update Into Queue
     |
     ▼
Notify React Scheduler
     |
     ▼
Schedule Render
     |
     ▼
Render Phase
     |
     ▼
Reconciliation
     |
     ▼
Commit Phase
     |
     ▼
DOM Updated

The sections below walk through each stage.

Step 1: an update record is created

Calling setCount(1) never re-renders anything directly:

setCount(1);

React creates an update record, which you can think of as a note reading:

Apply this update later.

That record is attached to the Hook's internal state. Hook data lives alongside the component's Fiber, the internal node React keeps for each component instance, and the update queue lives there too:

Fiber
   |
   └── useState
           |
           ├── Current State
           └── Update Queue

This is also why Hooks must be called in the same order on every render: React finds each Hook's state and queue by its position in that list.

Step 2: React schedules the work

With an update in hand, React has to decide when to process it. This is the scheduler's job. React does not necessarily render the instant a setter is called; it balances responsiveness against doing unnecessary work.

Picture a user typing quickly into a field:

A
AB
ABC
ABCD
ABCDE

Rendering expensive parts of the UI synchronously after every single keystroke, with no way to prioritize, would make heavy screens feel sluggish. Scheduling lets React combine updates that arrive together and, with concurrent features such as transitions, treat urgent updates like the input itself differently from less urgent ones like a filtered results list. That ability is a large part of why React's architecture moved toward scheduled rather than immediate updates.

Step 3: the render phase runs the component again

When React decides it is time to process pending updates, it starts a new render. Rendering simply means calling the component function again:

function Counter() {
    const [count, setCount] = useState(0);

    return <h1>{count}</h1>;
}

The important detail is what useState does during this call. Before handing a value back, it processes the queued updates against the previous state. Starting from:

Previous State = 0

React walks the queue:

Queue:
[ +1 ]Process QueueResult:
1

and the value useState hands back is now:

count = 1

which the component uses for this render. That is why the new value becomes visible only in the next render: it is computed there, not at the moment you call the setter.

Step 4: a new element tree is produced

Running the component returns a fresh tree of React elements. Side by side with the previous render, it looks like this:

Previous Render
<h1>0</h1>

New Render
<h1>1</h1>

The browser DOM has still not been touched. All React holds at this point is an updated blueprint of the intended UI.

Step 5: reconciliation finds the difference

Next, React compares the previous result:

Old Tree

against the new one:

New Tree

asking what actually changed. In this example, before:

Before:
<h1>0</h1>

and after:

After:
<h1>1</h1>

Only the text inside the heading differs, so that is the only change React records. This comparison is what the term reconciliation refers to. For a closer look at how React decides what to keep and what to replace, see building a mental model for React reconciliation, state and Hooks.

Step 6: the commit phase touches the DOM

With the list of changes known, React enters the commit phase and applies them to the real DOM:

DOM Before
<h1>0</h1>

DOM After
<h1>1</h1>

Only now does the user see the new number. This is the moment most people picture happening when they call a setter, yet it is the last of several stages.

Why React does not apply updates immediately

Consider a handler that updates several pieces of state at once:

setCount(c => c + 1);
setLoading(false);
setUser(data);

If each call triggered its own render, you would get:

Render 1
Render 2
Render 3

That is three renders, two of them wasted, and possibly intermediate screens showing inconsistent combinations of state. Instead, React batches the updates. The calls are queued:

Update
Update
Update

and then handled together:

      |
      ▼Single Render

Batching avoids redundant renders and ensures the user only ever sees a consistent final state. It is the main reason React prefers scheduling over executing each update on the spot. React can also skip work entirely: if an update produces a value identical to the current one, as judged by Object.is, React may bail out without re-rendering the component's children.

What this looks like in practice

Take a search box where every keypress updates three pieces of state:

query
results
loading

It is natural to assume each of those setters causes a separate render. Profiling such a component usually shows otherwise: updates made together in the same event are batched, so the component renders fewer times than a naive reading of the code suggests. React's job is not only to apply updates, but to apply them efficiently.

When several components have pending updates

Updates are not limited to one component. Consider a tree like this:

App
 ├── Header
 ├── Sidebar
 └── Dashboard

If several updates land in different parts of this tree, React does not need to rebuild everything blindly. The Fiber tree lets React track:

  • which components have pending work
  • where in the tree each update originated
  • which subtrees need to be visited and which can be skipped

This is what allows React to be much more selective than a "re-render the whole app on every change" approach. Note that a component that re-renders still re-renders its children by default; memoization is what lets unchanged subtrees be skipped.

Revisiting the stale console.log

Back to the snippet from the start:

setCount(count + 1);
console.log(count);

The console prints:

0

because the code is still running inside the current render. The queue has not been processed, the next render has not happened and the update is waiting its turn. A more accurate way to picture it:

Current Render
count = 0

Request Update
Future Render
count = 1

If you need the new value right away, compute it into a local variable and use that, or read it in the next render or in an effect that depends on it. Seen this way, the behavior is not strange at all.

How the pieces connect

Put together, the stages form a single chain:

  • State is stored with a component's Hook data.
  • Hook data is attached to the component's Fiber.
  • Calling a setter creates an update.
  • Updates are appended to the Hook's queue.
  • The scheduler picks the moment to work through them.
  • The render phase calls the component again and applies the queue.
  • Reconciliation compares the new element tree with the old one.
  • The commit phase applies the differences to the DOM.

Concepts that often get taught separately are really consecutive steps in one pipeline. Compressed into one sentence: a setter call leaves state as it is and instead requests a scheduled update, which React applies on a later render, diffs against the previous output, and commits to the DOM only where something differs.

Key takeaways

  • A setter never mutates state in place; it enqueues an update for React to handle afterwards.
  • Updates are queued per Hook, so several of them can be handled in a single render.
  • Plain values replace state; updater functions derive the next state from the latest one, which avoids stale values.
  • React schedules work rather than rendering on every call, which is what makes batching and prioritization possible.
  • Rendering and updating the DOM are separate stages: the render produces a description, and the commit phase changes the browser.
  • The update queue lives with the Hook state on the component's Fiber.

Treating setState as a request rather than a command is a small change in wording with large consequences. It is the reason batching, scheduling, reconciliation and concurrent rendering are possible at all. The natural next question is how React decides whether queued updates produce one render or many, which is what automatic batching, introduced in React 18, addresses.