Skip to content
aviral gupta

// B3.4 · ~30 min · Beginner

State as a snapshot and batching

After this lesson you can predict what a handler sees after setting state, explain batching, and use updater functions to change the same state several times.

Lesson 4 of 6 in B3 Adding interactivity

You will be able to

  • Predict that a handler, and a timer it starts, sees the state of its own render
  • Explain batching and predict the result of a queue of values and updater functions
  • Write pure updater functions with conventional names, knowing Strict Mode calls them twice
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B3.3: render and commit. A program keeps the <p> in a variable, before, then clicks +10 twice. It prints whether the <p> on the page is still before, and its text. What does it print?

    function Score() {
      const [score, setScore] = useState(0);
    
      return (
        <>
          <p>{score}</p>
          <button onClick={() => setScore(score + 10)}>+10</button>
        </>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. The button calls setNumber(number + 1) three times. You click it once. What does the <h1> show?

    function Counter() {
      const [number, setNumber] = useState(0);
    
      return (
        <>
          <h1>{number}</h1>
          <button
            onClick={() => {
              setNumber(number + 1);
              setNumber(number + 1);
              setNumber(number + 1);
            }}
          >
            +3
          </button>
        </>
      );
    }
  3. Practice · Activity 3 of 7

    The handler sets number to number + 5, then starts a 50 ms timer that logs number. The timer runs after the re-render. What is logged, and what does the <h1> show? (logged / shown)

    function Counter() {
      const [number, setNumber] = useState(0);
    
      return (
        <>
          <h1>{number}</h1>
          <button
            onClick={() => {
              setNumber(number + 5);
              setTimeout(() => {
                console.log(number);
              }, 50);
            }}
          >
            +5
          </button>
        </>
      );
    }
  4. Practice · Activity 4 of 7

    Match each piece of code, inside a click handler, to what it means.

  5. Practice · Activity 5 of 7

    Fill in the argument for each of the three calls, so that one click adds 3 (and two clicks show 6).

    function handleClick() {
      setNumber(____);
      setNumber(____);
      setNumber(____);
    }
    setNumber();
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The handler mixes updater functions and a value. number starts at 0. You click once. What does the <h1> show?

    function Counter() {
      const [number, setNumber] = useState(0);
    
      return (
        <>
          <h1>{number}</h1>
          <button
            onClick={() => {
              setNumber(n => n + 1);
              setNumber(number + 5);
              setNumber(n => n * 2);
            }}
          >
            Go
          </button>
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: a score keeper. In main.jsx, write a ScoreKeeper component with a score state, starting at 0, shown in an <h1>. A +3 button adds 3 with three set calls. A Restart at 1 button sets the score to 0 and then adds 1, in two set calls. Run it, click +3 twice, then Restart at 1. For each handler, write down the queue React works through.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Three set calls with values, then with updaters

Counter has two buttons that each try to add 3. One passes values, setNumber(number + 1); the other passes updater functions, setNumber(n => n + 1). Each handler also logs number after its three calls. A run only mounts, so timers at the bottom of main.jsx click each button once and log the heading after the re-render. In the browser the page stays live: click the buttons yourself.

main.jsx

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  function addThreeValues() {
    setNumber(number + 1);
    setNumber(number + 1);
    setNumber(number + 1);
    console.log('values: number is still', number);
  }

  function addThreeUpdaters() {
    setNumber(n => n + 1);
    setNumber(n => n + 1);
    setNumber(n => n + 1);
    console.log('updaters: number is still', number);
  }

  return (
    <>
      <h1>{number}</h1>
      <button onClick={addThreeValues}>+3 with values</button>
      <button onClick={addThreeUpdaters}>+3 with updaters</button>
    </>
  );
}

// A run only mounts. Act like a user: click each button once.
setTimeout(() => {
  const [values, updaters] = document.querySelectorAll('button');
  values.click();
  setTimeout(() => {
    console.log('after values:', document.querySelector('h1').textContent);
    updaters.click();
    setTimeout(() => {
      console.log('after updaters:', document.querySelector('h1').textContent);
    }, 50);
  }, 50);
}, 50);

Run it with

npm run dev

Output

values: number is still 0
after values: 1
updaters: number is still 1
after updaters: 4
<!-- #root -->
<div id="root"><h1>4</h1><button>+3 with values</button><button>+3 with updaters</button></div>
  • Inside both handlers number is still the value of the render they belong to: setting state does not change it.
  • Three values are three times "replace with 0 + 1", so the first click adds only 1.
  • Three updaters are queued and run in order, 1 + 1, 2 + 1, 3 + 1, so the second click adds 3.
  • Each handler logs once per click; only component bodies and updaters run twice in Strict Mode.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 2

Fix the +3 button

Counter has a +3 button that only adds 1, and a "Double, then +1" button that should turn 3 into 7 but does not. Both handlers pass values computed from this render's number. Change them to updater functions, so that each set call builds on the one before. Keep the buttons' text.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    number is the same in every line of a handler. Which kind of argument gets the state so far instead?

  2. Hint 2

    Write setNumber(n => n + 1) for each step of +3.

  3. Hint 3

    Double, then +1 is two updaters: setNumber(n => n * 2); then setNumber(n => n + 1);.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  function handlePlusThree() {
    setNumber(n => n + 1);
    setNumber(n => n + 1);
    setNumber(n => n + 1);
  }

  function handleDoublePlusOne() {
    setNumber(n => n * 2);
    setNumber(n => n + 1);
  }

  return (
    <>
      <h1>{number}</h1>
      <button onClick={handlePlusThree}>+3</button>
      <button onClick={handleDoublePlusOne}>Double, then +1</button>
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  // TODO: +3 only adds 1, and "Double, then +1" is wrong too.
  function handlePlusThree() {
    setNumber(number + 1);
    setNumber(number + 1);
    setNumber(number + 1);
  }

  function handleDoublePlusOne() {
    setNumber(number * 2);
    setNumber(number + 1);
  }

  return (
    <>
      <h1>{number}</h1>
      <button onClick={handlePlusThree}>+3</button>
      <button onClick={handleDoublePlusOne}>Double, then +1</button>
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './main.jsx';

const number = () => screen.getByRole('heading', {level: 1}).textContent;
const button = (name) => screen.getByRole('button', {name});

test('One click on +3 shows 3', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  await user.click(button('+3'));
  assert.equal(number(), '3', 'after one click on +3 the counter shows ' + JSON.stringify(number()));
});

test('Three clicks on +3 show 9', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  for (let i = 0; i < 3; i++) await user.click(button('+3'));
  assert.equal(number(), '9', 'after three clicks on +3 the counter shows ' + JSON.stringify(number()));
});

test('+3, then Double, then +1, shows 7', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  await user.click(button('+3'));
  await user.click(button('Double, then +1'));
  assert.equal(number(), '7', 'from 3, Double, then +1 shows ' + JSON.stringify(number()));
});

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Exercise 2 of 2

Fix a request counter

RequestTracker counts orders: each Buy adds a pending order, and after a short delay that order moves from Pending to Completed. Click Buy: Pending shows 1, then -1. Click twice quickly and an order goes missing. After the await, the handler still uses the pending and completed of the render it started in. Fix both counters with updater functions.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    After await delay(50), pending and completed are the snapshot from the click's render, not the current state.

  2. Hint 2

    An updater gets the state as it is when React processes it: setPending(p => p - 1).

  3. Hint 3

    Change all three set calls: setPending(p => p + 1), setPending(p => p - 1) and setCompleted(c => c + 1).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

function delay(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

export default function RequestTracker() {
  const [pending, setPending] = useState(0);
  const [completed, setCompleted] = useState(0);

  async function handleClick() {
    setPending(p => p + 1);
    await delay(50);
    setPending(p => p - 1);
    setCompleted(c => c + 1);
  }

  return (
    <>
      <h3>Pending: {pending}</h3>
      <h3>Completed: {completed}</h3>
      <button onClick={handleClick}>Buy</button>
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

function delay(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

export default function RequestTracker() {
  const [pending, setPending] = useState(0);
  const [completed, setCompleted] = useState(0);

  // TODO: after the delay, Pending goes to -1, and quick clicks lose orders.
  async function handleClick() {
    setPending(pending + 1);
    await delay(50);
    setPending(pending - 1);
    setCompleted(completed + 1);
  }

  return (
    <>
      <h3>Pending: {pending}</h3>
      <h3>Completed: {completed}</h3>
      <button onClick={handleClick}>Buy</button>
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import RequestTracker from './main.jsx';

const pending = () => screen.getByText(/^Pending:/).textContent;
const completed = () => screen.getByText(/^Completed:/).textContent;
const buy = () => screen.getByRole('button', {name: 'Buy'});

test('Buy shows one pending order at once', async () => {
  const user = userEvent.setup();
  render(<RequestTracker />);
  await user.click(buy());
  assert.equal(pending(), 'Pending: 1', 'right after Buy the page shows ' + JSON.stringify(pending()));
});

test('After the delay the order is completed and nothing is pending', async () => {
  const user = userEvent.setup();
  render(<RequestTracker />);
  await user.click(buy());
  await waitFor(() => assert.equal(completed(), 'Completed: 1', 'after the delay the page shows ' + JSON.stringify(completed())));
  assert.equal(pending(), 'Pending: 0', 'after the delay the page shows ' + JSON.stringify(pending()));
});

test('Two quick clicks complete two orders', async () => {
  const user = userEvent.setup();
  render(<RequestTracker />);
  await user.click(buy());
  await user.click(buy());
  await waitFor(() => assert.equal(completed(), 'Completed: 2', 'after two orders the page shows ' + JSON.stringify(completed())));
  assert.equal(pending(), 'Pending: 0', 'after two orders the page shows ' + JSON.stringify(pending()));
});

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Common mistakes

Using the updater's argument without the arrow

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <button onClick={() => setNumber(n + 1)}>{number}</button>
  );
}

// Act like a user: click once.
setTimeout(() => document.querySelector('button').click(), 50);

What React prints

ReferenceError: n is not defined

Why, and the fix

n exists only as the parameter of an updater function. Without n =>, setNumber(n + 1) reads a variable n that nobody declared, so the click throws an uncaught ReferenceError and nothing changes. Write the updater in full: setNumber(n => n + 1).

Returning the result of push from an updater

import { useState } from 'react';

export default function Tags() {
  const [tags, setTags] = useState(['react']);

  return (
    <>
      <ul>
        {tags.map(tag => <li key={tag}>{tag}</li>)}
      </ul>
      <button onClick={() => setTags(t => t.push('state'))}>Add</button>
    </>
  );
}

// Act like a user: click once.
setTimeout(() => document.querySelector('button').click(), 50);

What React prints

TypeError: tags.map is not a function

Why, and the fix

An updater must return the next state. push changes the array in place and returns its new length, a number, so tags becomes a number and the render throws (Chrome and Node wording; Firefox and Safari word it differently). Changing the array is impure too, and Strict Mode runs the updater twice. Return a new array: setTags(t => [...t, 'state']).

Forgetting return in an updater with braces

import { useState } from 'react';

export default function Tags() {
  const [tags, setTags] = useState(['react']);

  return (
    <>
      <ul>
        {tags.map(tag => <li key={tag}>{tag}</li>)}
      </ul>
      <button onClick={() => setTags(t => { [...t, 'state']; })}>Add</button>
    </>
  );
}

// Act like a user: click once.
setTimeout(() => document.querySelector('button').click(), 50);

What React prints

TypeError: Cannot read properties of undefined (reading 'map')

Why, and the fix

With braces, an arrow function needs return; without it, the updater returns undefined, which becomes the next state, and the render throws (Chrome and Node wording; Firefox and Safari word it differently). Drop the braces, t => [...t, 'state'], or write t => { return [...t, 'state']; }.

React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Each render gets a snapshot of the state

Setting state does not change the variable you already have; it requests a new render. When React calls your component, it hands it the state for that render, and the JSX, props and event handlers are all calculated from it. So inside one render's handler, number stays 0 even after setNumber(number + 1): only the next render sees 1. A timer started in that handler reads the same 0, even if it runs after the re-render. react.dev suggests substituting the values: in this render, the handler reads setNumber(0 + 1). Every render has its own event handlers with their own snapshot.

Batching and the queue of updates

React waits until all code in an event handler has run before it processes the state updates, then renders once. This is batching: it avoids half-finished renders and too many re-renders. Each click is handled on its own. Every set call adds an entry to a queue. A value, such as setNumber(number + 1) or setNumber(5), means "replace with this value". An updater function, such as setNumber(n => n + 1), means "take the state so far and calculate the next one". On the next render React works through the queue in order: three updaters from 0 give 3; 5 then n => n + 1 gives 6; adding setNumber(42) after them gives 42.

Updater functions: pure, and named by convention

Use an updater when the next state depends on the previous one and you update it several times in one event, or after an await, where the snapshot is stale. Updaters run during rendering, so they must be pure: only return the next state, no set calls and no other side effects inside. In development Strict Mode runs each updater twice and discards the second result, so a console.log inside prints twice and an impure updater shows its bug. Name the argument after the state: setEnabled(e => !e), setCount(c => c + 1), or repeat the full name, setEnabled(enabled => !enabled). B1.3's setCount((c) => c + 1) was an updater.

Sources

Last reviewed October 5, 2026