While using React, I had always known that "useState works asynchronously." I wanted to dig deeper into how it actually works by going through the official documentation.
Quiz
If you click the button once in the code below, what will the value of number be?
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}
>
+3
</button>
</>
);
}
Intuitively, you might think the answer is 3 since we called setNumber(number + 1) three times — but the actual answer is 1.
Why does this happen?
State as a Snapshot
When you call useState, React calculates the state and provides a snapshot of the state needed for the current render. This value stays constant throughout that render phase.
That's why the number used in setNumber(number + 1) is fixed at 0, no matter how many times you call setNumber.
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);
So how does React handle these three calls to setNumber(0 + 1)?
React "batches" State Updates
React "batches" state updates
That's exactly how the official documentation describes it. The word "batch" means "to process as a single unit." In other words, React waits for all the code in an event handler to finish executing before processing all the state updates at once.
This is what React calls batching, and it optimizes rendering by preventing multiple re-renders from occurring within a single event.
This batched state processing happens before the next render. Each setNumber call is queued up and executed in order.
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);
These consecutive calls to setNumber(0 + 1) line up in the queue one by one.
We can visualize the queue like this:
queue: [setNumber(0 + 1), setNumber(0 + 1), setNumber(0 + 1)];
Now let's process them one by one:
- Dequeue
setNumber(0 + 1)—numberbecomes 1. - Dequeue
setNumber(0 + 1)—numberbecomes 1. - Dequeue
setNumber(0 + 1)—numberbecomes 1. - Queue is empty. The final value of
numberis 1.
On the next render, React provides the component a snapshot of number = 1.
Passing an Updater Function to useState
To ensure state is updated based on the previous value, you can pass a callback function to the state setter like this:
setSomething((prevState) => prevState + 1);
Let's fix the earlier example using this approach:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
}}
>
+3
</button>
</>
);
}
Now, what will number be after clicking the button once? The answer is 3!
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
Each setNumber(n => n + 1) call is queued in order just like before:
queue: [
setNumber((n) => n + 1),
setNumber((n) => n + 1),
setNumber((n) => n + 1),
];
Processing them one by one:
- Dequeue
setNumber(n => n + 1). The previous state (n) was 0, so the current state becomes 1 (n + 1). - Dequeue
setNumber(n => n + 1). The previous state (n) was 1, so the current state becomes 2 (n + 1). - Dequeue
setNumber(n => n + 1). The previous state (n) was 2, so the current state becomes 3 (n + 1). - Queue is empty. The final value of
numberis 3.
Now a question might come to mind: didn't setNumber(number + 1) also have a previous state? Why didn't it work the same way?
When Passing a Direct Value, the Previous State Is Ignored
Actually, setNumber(number + 1) did have a previous state internally — it just wasn't used.
setNumber(number + 1)issetNumber(0 + 1), andsetNumber(0 + 1)is equivalent tosetNumber(n => 0 + 1).
If you convert setNumber(number + 1) to a callback form, it becomes setNumber(n => number + 1). Since number is already fixed at 0, n simply isn't used in the calculation.
So what happens when you mix both approaches — passing a direct value and passing an updater function — within the same component?
Another Quiz
What will the value of number be in the example below? Will it be 1? 5? Or 6?
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>
</>
)
}
The answer is 6!
Let's trace through the queue like before. First, remember that number is fixed at 0 during the current render phase.
setNumber(0 + 5);
setNumber((n) => n + 1);
These calls line up in the queue:
queue: [setNumber(0 + 5), setNumber((n) => n + 1)];
Processing them one by one:
- Dequeue
setNumber(0 + 5). This is equivalent tosetNumber(n => 0 + 5), sonis ignored and the current state becomes 5. - Dequeue
setNumber(n => n + 1). The previous state was 5, so it becomessetNumber(5 => 5 + 1), making the current state 6.
Summary
State updates do not change the variable in the current render phase — they request a new render.
React waits for all the code in an event handler to finish executing before processing state updates all at once. This process is called batching.
To update the same state multiple times within a single event, pass an updater function to the setter — like
setNumber(n => n + 1).
References