Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 15 of 38

State updates, snapshots and batching

Understand why React state behaves like a snapshot per render, why three setCount calls add one, and how updater functions and batching work.

18 minState and eventsUpdated 24 October 2026

By the end of this lessonPredict what happens when you call a state setter several times, use updater functions to build on the latest value, and explain batching.

Many React bugs come from one misunderstanding: thinking of state as a variable that changes at once. It is not. This lesson corrects that picture. Once you see state as a snapshot, strange behaviours (a counter that only goes up by one, a stale value in a timer) become predictable.

Rendering takes a snapshot

When React renders your component, it calls the function and gets back JSX. The values of props and state during that call are fixed, like a photograph taken at that moment. Everything created in that render, including the handler functions and the values in the JSX, "sees" those values. When state changes, React does not alter the old photograph. It takes a new one by rendering again.

Test this against your intuition. What do you think the console prints, and what does the button show afterwards?

jsx
import { useState } from "react";

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

  function handleClick() {
    setCount(count + 1);
    console.log("after setCount, count is", count);
  }

  return <button onClick={handleClick}>Count: {count}</button>;
}

export default App;
result
Count: 1
CONSOLE
after setCount, count is 0

The log says 0, even though the screen shows 1. Inside this render's handleClick, count is a constant that equals 0, because that is what it was when the snapshot was taken. setCount(1) does not change that variable; it asks React to render again with a new value, and the next snapshot will have count equal to 1.

Why three updates add only one

Because count is fixed during the handler, this is not what it looks like:

jsx
import { useState } from "react";

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

  function handleClick() {
    setCount(count + 1);   // setCount(0 + 1)
    setCount(count + 1);   // setCount(0 + 1)
    setCount(count + 1);   // setCount(0 + 1)
  }

  return <button onClick={handleClick}>Count: {count}</button>;
}

export default App;
result
Count: 1

One click gives 1, not 3. All three calls say "set the count to 0 + 1", i.e. to 1. They are requests with the same value, not instructions to add.

Updater functions

When you want to say "add one to whatever the latest value is", pass a function to the setter instead of a value. It is called an updater function; it receives the pending state and returns the next one:

jsx
import { useState } from "react";

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

  function handleClick() {
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
  }

  return <button onClick={handleClick}>Count: {count}</button>;
}

export default App;
result
Count: 3

Now one click adds 3. React puts the updater functions in a queue. After the handler finishes, it processes the queue in order: the first updater receives 0 and returns 1, the second receives 1 and returns 2, the third receives 2 and returns 3. The component then renders once with 3.

By convention, the updater's parameter is named after the state, shortened to its first letter(s): c => c + 1, prev => prev + 1, todos => ....

When to use which form

Situation Use
The new value does not depend on the old one (setName("Ravi"), setOpen(false)) the value
The new value is calculated from the old value (count + 1, adding to an array, toggling) an updater function
You may call the setter several times in a row, or in a timer or async code an updater function

Using the updater form for anything based on the previous value is a safe habit. It is never wrong, and it avoids stale-snapshot bugs.

Mixing values and updaters

The queue can hold both kinds. Predict the final count for one click, starting at 0, before you read the answer:

jsx
import { useState } from "react";

function App() {
  const [number, setNumber] = useState(0);

  function handleClick() {
    setNumber(number + 5);        // queue: "replace with 5"
    setNumber(n => n + 1);        // queue: "take the pending value and add 1"
    setNumber(42);                // queue: "replace with 42"
  }

  return <button onClick={handleClick}>Number: {number}</button>;
}

export default App;
result
Number: 42

React processes the queue in order: start at 0, replace with 5, the updater turns 5 into 6, then replace with 42. The final value is 42, because the last replacement wins.

Batching: one render for many updates

You may wonder whether three calls cause three renders. They do not. React batches updates: it waits until your handler has finished running, applies the whole queue, and renders once. This avoids showing half-finished screens and wasted work. To see it, watch how many times the component renders (it logs on each render, and StrictMode runs every render twice in development, so you will see the message in pairs):

jsx
import { useState } from "react";

function App() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  console.log("render");

  function handleClick() {
    setA(a + 1);
    setB(b + 1);
  }

  return <button onClick={handleClick}>a = {a}, b = {b}</button>;
}

export default App;
result
a = 1, b = 1
CONSOLE
render
render
render
render

The first pair of render lines comes from the first render. After the click, two setters ran but only one more pair appeared, so one batched render updated both numbers. Batching also applies in timeouts, promises and other async code in React 18 and later; older versions only batched inside React event handlers.

One more consequence: if you set state to the same value it already has, React skips the render (it compares with Object.is). That is also why mutating an object and passing the same object back does nothing, a topic of the next lessons.

Stale snapshots in timers

Because a handler belongs to the render in which it was created, anything delayed uses the old snapshot. This is not a bug; it is how closures work:

jsx
import { useState } from "react";

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

  function handleLater() {
    setTimeout(() => {
      console.log("one second later, this render saw count =", count);
    }, 1000);
  }

  return (
    <div>
      <button className="add" onClick={() => setCount(count + 1)}>Add (count is {count})</button>
      <button className="later" onClick={handleLater}>Log later</button>
    </div>
  );
}

export default App;
result
Add (count is 3)Log later
CONSOLE
one second later, this render saw count = 1

In the test I added 1, pressed "Log later" (while the count was 1), then added two more. One second later the log still reports 1: the timer's function was created in the render where the count was 1, and that render's count never changes. If you want "the latest value" in delayed code, use an updater function (it receives the newest pending state) or a ref, a tool covered in the Refs lesson.

What is a render, exactly?

It helps to use a precise vocabulary:

  1. Trigger. Something requests a render: the first mount, or a state setter being called.
  2. Render. React calls your component functions to work out what the screen should look like. Nothing is shown yet.
  3. Commit. React updates the page: it changes only the DOM nodes that differ from the previous result.

A component renders again when its own state changes or when its parent renders (a child re-renders along with its parent by default). Setting state during a click does not interrupt the handler: the rest of the handler runs with the old snapshot, and the render happens after.

A mental checklist

When something looks stale, ask:

  • Which render is this code from? What were the values then?
  • Did I use the value (count + 1) where I meant an updater (c => c + 1)?
  • Am I reading state right after setting it, in the same function? That reads the old snapshot.
  • Is a timer, interval or async callback holding on to an old snapshot?

Common mistakes

  • Expecting a state variable to change immediately after calling its setter.
  • Calling setCount(count + 1) several times and expecting count + 3.
  • Reading count in a setTimeout and expecting the newest value.
  • Forgetting that the updater function must be pure and return the next state; do not call other setters inside it.
  • Relying on the number of renders, which StrictMode doubles in development.
  • Putting side effects such as console.log or network calls inside an updater function.

Exercise

  1. Change the triple-count + 1 example so the button adds exactly 3, using updater functions.
  2. Predict the result of setNumber(n => n + 1); setNumber(n => n * 2); setNumber(10); starting from 3, then try it.
  3. Create a button that increases the count by 1 now and by another 1 after one second (with setTimeout). Which form of setter do you need so that two quick clicks end with the right total?
  4. Add console.log("render") to one of your earlier components and count the logs when the state changes.

Key points

  • Each render has its own snapshot of props and state; handlers and timers created in that render see those values, not later ones.
  • setCount(count + 1) three times adds only 1, because count is the same snapshot value each time.
  • Pass an updater function, setCount(c => c + 1), to build on the latest queued value; this adds 3 for three calls.
  • React batches the updates made in one event and renders once afterwards, in handlers, timeouts and promises alike.

Check yourself

1. A handler calls setCount(count + 1) three times, with count = 0. What is count after the next render?
2. What does setCount(c => c + 1) do three times?
3. What is batching?
4. In a click handler you call setTimeout(() => console.log(count), 1000) and click again after 500 ms, changing count. What does the first timer log?