Skip to content
How does ReactJS batch multiple updates to a state?

How does ReactJS batch multiple updates to a state?

Alright, let's kick off this blog by assessing your React fundamentals - because who doesn't love a good code riddle to warm up their brain?

Look at this React snippet and figure out what will be displayed when the button is clicked.

const App = () => {
  const [count, setCount] = useState(0);
 
  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };
 
  return (
    <>
      <p>Count is {count}</p>
      <button onClick={handleClick}>Increment</button>
    </>
  );
};
  • If you feel the output will be 3, hang tight and read this blog to the end.
  • If you feel the output will be 1, hang tight and read this blog to the end.
  • If you feel the output will be some other value, hang tight and read this blog to the end.

Did you see what I did there? You will stay till the end of this blog no matter what you state as your answer!

What is a state?

We can think of states as a memory that React holds between multiple renders of a component.

Change to a state can trigger a re-render on the components that use the state, as well as on the descendant components.

We need to clearly understand that a regular JavaScript variable declared within a component lives till the end of the component because a component is just a JavaScript function and the variable is scoped within it. But a state on the other hand lives on even between multiple invocations of the function, or component.

How does React process state updates?

It's very crucial to understand that a state's value never changes within a particular render. Now what do I mean by that? Let's consider the above example. A high-level overview would be:

  • During the initial render of the component, the state is initialized with the value 0.
  • The second render gets triggered whenever we click the button.
  • When the button gets clicked we need to invoke setCount 3 times.
📝

The following logic is a generalization, but it's an easy way to remember how React figures out the value of the state for next render.

Interally React maintains a queue to handle the updates to a state. So whenever React encounters a state setter, it goes through the following steps:

  1. Is the argument passed to setState a function or a value?
  2. If it's a value, then add the command "Ignore all previous values in the queue and replace state by given value" into the queue.
  3. If it's a function, then add the given function to the queue.
  4. Once all the statements are processed, prepare for the next render.
  5. During next render, process the queue by going through the items in the queue in First In First Out(FIFO) order and the last item to be dequeued will be the final value of the state.

At first glance, these steps might appear a bit complex, but they'll all fall into place soon.

Let's walk through our setCount calls by adhering to above mentioned steps:

const handleClick = () => {
  setCount(count + 1); // first
  setCount(count + 1); // second
  setCount(count + 1); // third
};
  • In the first call, a value is passed as the argument. And the value is count + 1, which is 0 + 1. So we add Replace by 1 into the queue.
  • In the second call, the argument passed is again a value. And the value is count + 1. This is where most people make the mistake and think count is now 1, but that's not the case. count is still 0 as React hasn't yet processed what's in the queue. So again, here the value will be 0 + 1, which is 1. Thus we add Replace by 1 into the queue.
  • In the third call, the same logic as the second call gets applied and we add Replace by 1 into the queue.

Once all invocations are dealt with, and before the next render, the queue would look like so:

queue = [
  "Replace by 1", // first
  "Replace by 1", // second
  "Replace by 1", // third
]

Now React will process the queue in FIFO order:

  1. Dequeue and we get the command Replace by 1, which is asking us to replace the state's value with whatever is mentioned. Thus replace the state's value as 1. Thus count is replaced with value 1.
  2. Same logic as the previous step and count is 1.
  3. Same logic as the previous step and count is 1.
  4. The queue is empty now.

So the final value of the state for next render would be 1.

We can apply these same steps to figure out what a state would hold once all updates are done.

How to get desired output?

Although the output here is 1, we(whoever wrote this nasty code) were hoping to get 3 as the output. In order to get that, we need to pass an updater function as argument to the setCount method.

Basically, the updater function is a way by which React allows us to use the previous state value to update the current state. It's just a JavaScript callback.

Speaking in terms of our queue, the updater function takes the last enqueued value from the queue and computes the logic of the updater function.

So to fix the issue, we can modify the code like so:

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

Let's take a look at how React processes the queue:

  • setCount(prevCount => prevCount + 1): Function is passed as argument, thus insert it into the queue.
  • setCount(prevCount => prevCount + 1): Apply same logic as previous step.
  • setCount(prevCount => prevCount + 1): Apply same logic as previous step.

Before the next render queue will look like so:

queue = [
  prevCount => prevCount + 1,
  prevCount => prevCount + 1,
  prevCount => prevCount + 1,
]

In the first encounter prevCount is the same as the initial value that we provide to setCount during declaration, that's the value will be 0 initially.

So the queue processing would look like so:

  • prevCount => prevCount + 1: State will be 0 => 0 + 1, that's 1.
  • prevCount => prevCount + 1: State will be 1 => 1 + 1, that's 2.
  • prevCount => prevCount + 1: State will be 2 => 2 + 1, that's 3.

So 3 is the value that's going to be set to count on the next render.

Another example

Take a look at the following variation of the above mentioned handleClick function and try to figure out what will be the output. Retrace using the queue based steps to figure out the answer:

const handleClick = () => {
  setCount(count + 5);
  setCount(prev => prev + 2);
  setCount(count + 0.1);
  setCount(prev => prev + 1);
}

Let's walk through the code and find the answer:

  • setCount(count + 5): Add Replace by 0+5 into the queue.
  • setCount(prev => prev + 2): Add the function into the queue.
  • setCount(count + 0.1): Add Replace by 0+0.1 into the queue.
  • setCount(prev => prev + 1): Add the function into the queue.

Now let's process the queue:

  • Replace by 5: State will be 5.
  • prev => prev + 2: State will be 5 + 2, that's 7.
  • Replace by 0.1: State will be 0.1.
  • prev => prev + 1: State will be 0.1 + 1, that's 1.1.

So for the next render, the value that will be set for count is 1.1.

What exactly is batching?

The queueing logic that we saw above is exactly what batching is. When React sees multiple state updates within a render, it batches them and performs all of them in one go before the next render.

This significantly helps improve the performance of the app because it avoids re-renders.

The "state is updated asynchronously" logic

During some of the interviews I have taken, I have asked candidates the same question with which we kickstarted this blog. Some of them answered correctly and when I asked them how they reached their answer, most of them said "It's because state updates are asynchronous".

They're not wrong. I mean this is one of the most commonly asked questions in Stack Overflow[1] and the answers over there resonates with what the candidates say. State updates do happen asynchronously. But does that help in figuring out what value the state will hold for the next render? Maybe, maybe not! I often follow up with the question given below to see how they answer it.

What do you think will be displayed in the alerts given that each of the alerts is called after a delay of 2 seconds? Do you think that the 2 second delay is good enough for the asynchronous state update to be completed and for the alert to receive an updated state value?

const ping = () =>
  setTimeout(() => {
    alert(count);
  }, 2000);
 
const handleClick = () => {
  setCount(count + 1);
  ping();
  setCount(prev => prev + 1);
  ping();
};

Sadly most of them end up saying the output will be 2 for both alerts. But that's not the case. The output displayed will be 0 for both. This is again because during an ongoing render, React doesn't change the value of the state. It rather queues up the state updates and changes the state's value before the next render.

So in the above code, in the ongoing render, the value of the state count is 0, which the alert will display.

What do you think will be displayed on a subsequent render when the button is again clicked?

Code smell

The examples we have used in this blog are purely educational. Writing multiple state update statements without combining them into a single update statement for that state is a code smell.

// BAD
const handleClick = () => {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
}
 
// GOOD
const handleClick = ({ times }) => {
  setCount(prev => increment(prev, times));
}

We can probably write an ESLint rule to fix this, which can be a topic for another blog.

Closure

At the end of the day since React lives in the JavaScript cuckoo land, we can probably figure out the answer to our initial question, if we think of state updates in terms of JavaScript closures. I mean useState is a closure. But I don't want to go there in this blog because we will both end up like this:

Image showing the frustration of understanding closure

References

  1. [1] A commonly referenced Stack Overflow answer (opens in a new tab) regarding why state updates are not reflected immediately.
  2. https://react.dev/reference/react/useState (opens in a new tab)
  3. https://yedhin.vercel.app/blog/why-hooks-cannot-be-invoked-conditionally-in-reactjs (opens in a new tab)