Ch. 3 · React

React State Snapshots and Event Handlers

Why setCount(count + 1) twice adds only one in React: how each render captures a state snapshot and how the update queue resolves it.

~7 min readbeginnerupdated Oct 6, 2026

“What does this counter show after one click?” The interviewer puts up a button whose handler calls setCount(count + 1) three times and waits. Many candidates answer three. The answer is one, and the explanation tells the interviewer whether you understand how React renders or whether you think of state as an ordinary mutable variable.

The question is popular because the same misunderstanding causes real bugs: a form that submits stale values, a timer that keeps logging zero, an analytics call that records the old filter. All of them come from reading state as if it changed the moment you set it.

Before you start

You should be comfortable writing a function component, calling useState, and attaching an onClick handler. It also helps to know what a JavaScript closure is: a function remembers the variables that were in scope when it was created. Everything below applies to React 18 and React 19 equally; the batching behaviour described here became universal in React 18, when automatic batching was extended to promises, timeouts and native event handlers.

The short answer

State in React is a snapshot. Every render calls your component function with fixed state values, and the event handlers created during that render close over those values. Calling setCount does not change count in the running handler; it queues an update and asks React to render again. Three calls of setCount(count + 1) with count equal to 0 all queue “replace with 1”, so the next render shows 1. To apply three increments, pass an updater: setCount(c => c + 1).

How it works

Think of a render as a photograph. When React calls Counter(), useState hands back the current value, and that value is a plain constant for the whole function body:

import { useState } from 'react';

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

  function handleClick() {
    setCount(count + 1); // queue: replace with 0 + 1
    setCount(count + 1); // queue: replace with 0 + 1
    setCount(count + 1); // queue: replace with 0 + 1
    console.log(count);  // 0, this render's snapshot
  }

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

count is declared with const. Nothing in the handler can reassign it. The setter writes into a queue that belongs to the hook, not into the local variable. After the handler finishes, React batches every queued update from that event, processes the queue once, and calls Counter() again. The new call receives the new value and creates new handlers that close over it.

The queue holds two kinds of entries. A plain value means “replace the state with this”. A function means “compute the next state from whatever the previous entry produced”. React walks the queue in order:

function processQueue(base, queue) {
  let state = base;
  for (const update of queue) {
    state = typeof update === 'function' ? update(state) : update;
  }
  return state;
}

processQueue(0, [1, 1, 1]);                        // 1
processQueue(0, [c => c + 1, c => c + 1]);         // 2
processQueue(0, [5, c => c + 1]);                  // 6
processQueue(0, [c => c + 1, 42, c => c * 2]);     // 84
JavaScript

That model is close to what React does internally and it predicts every puzzle of this kind.

Step-by-step walkthrough

Step 1: Identify which render you are in

Before predicting anything, name the snapshot. On the first render count is 0, so every handler created in that render sees 0 forever. Even a handler that runs ten seconds later through setTimeout still sees 0:

function handleClick() {
  setCount(count + 1);
  setTimeout(() => alert(count), 3000); // alerts 0, not 1
}
JSX

The timeout callback was created during render 0, so it reads render 0’s constant. The screen shows 1 long before the alert appears, which surprises people the first time.

Step 2: Write down the queue

Translate each setter call into a queue entry. setCount(count + 1) becomes the number 1, because count + 1 is evaluated immediately using the snapshot. Three identical calls produce three identical replacement entries. Replacing 0 with 1 three times leaves 1.

Step 3: Switch to functional updates when you depend on the previous value

An updater function defers the arithmetic until React processes the queue, so each entry sees the output of the one before it:

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

The queue is now three functions and the result is 3. The local count inside this handler is still 0; only the next render sees 3.

Step 4: Use a local variable when you need the new value right now

If the handler itself needs the next value, for example to send it to a server, compute it once and use that variable for both the setter and the side effect:

function handleSave() {
  const next = count + 1;
  setCount(next);
  saveCount(next); // sends 1, not the stale 0
}
JSX

This avoids reading state back, which is never possible inside the same handler.

Worked scenario

A product team has a quantity picker with a “+5” shortcut button. The handler looks reasonable:

function addFive() {
  for (let i = 0; i < 5; i++) {
    setQuantity(quantity + 1);
  }
  trackEvent('quantity_changed', { quantity });
}
JSX

Users report two bugs: “+5” adds one, and analytics records the old quantity. Both come from the snapshot. The loop queues five identical replacements, and trackEvent reads the value from the render that created the handler.

The fix separates calculation from the request:

function addFive() {
  const next = quantity + 5;
  setQuantity(next);
  trackEvent('quantity_changed', { quantity: next });
}
JSX

If the button could be clicked again before React re-renders (very rapid clicks are still processed in order, but a more complex flow might queue updates from several places), setQuantity(q => q + 5) would be the safer choice, with analytics moved to wherever the final value is known.

Common mistake

  • “setState is asynchronous, so I need to wait for it.” The setter is not a promise and cannot be awaited. The value never changes inside the current render; awaiting something before reading count still gives the old snapshot because the closure has not changed.
  • “Batching causes this.” Batching decides how many renders happen, not what count contains. Even without batching, the handler’s count would still be the constant from its render.
  • Reading state right after setting it to decide something. if (count > 10) after setCount(...) checks the old value. Compute the next value first, as in Step 4.
  • Using functional updates everywhere by reflex. They are needed when the next state depends on the previous one. For “set the field to what the user typed”, a plain value is clearer.

If your symptom is specifically that a log shows the old value, the companion guide on state not updating immediately walks through debugging it.

Verify the behavior

Paste this into any React sandbox and click once:

import { useState } from 'react';

export default function Demo() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  console.log('render', { a, b });

  return (
    <button
      onClick={() => {
        setA(a + 1); setA(a + 1); setA(a + 1);
        setB(x => x + 1); setB(x => x + 1); setB(x => x + 1);
        console.log('in handler', { a, b });
      }}
    >
      a={a} b={b}
    </button>
  );
}
JSX

The console shows in handler { a: 0, b: 0 } followed by one render { a: 1, b: 3 } (two renders in development under Strict Mode, with identical values). One render after six setter calls proves batching, and the two different results prove the queue semantics.

Follow-up questions

Does this change inside setTimeout or after await? Not since React 18. Automatic batching applies everywhere, and the snapshot rule never depended on batching anyway.

How do I read the latest state from an old closure, such as an interval? Use a functional updater if you only need to compute the next state, or mirror the value into a ref for reading. See refs versus state.

What is flushSync for? It forces React to apply queued updates synchronously, typically so the DOM reflects them before you measure or scroll. It does not change the count variable in your handler; you still hold the old snapshot.

Do objects behave differently? No. If state is an object, the snapshot is a reference to that object. Mutating it does not schedule a render; you must pass a new object to the setter.

Interview exercise

The state starts at 0. What does the button display after one click, and what does the alert show?

<button onClick={() => {
  setScore(score + 5);
  setScore(s => s + 1);
  setScore(42);
  setScore(s => s * 2);
  alert(score);
}}>{score}</button>
JSX

Answer and reasoning

The alert shows 0, because score in this closure is the render’s snapshot and is never reassigned. The queue is [5, s => s + 1, 42, s => s * 2]. Processing it: replace with 5, then 5 + 1 = 6, then replace with 42, then 42 * 2 = 84. The button displays 84 after the re-render. A good answer narrates the queue entry by entry and explains that the alert fires before the next render exists.

Continue learning

Practise more with the React interview questions and the React MCQs. Related notes: functional updates and pure state changes, state not updating immediately and refs versus state. Official reference: State as a Snapshot and Queueing a Series of State Updates on react.dev.

More in React

read ✓React · easy

React Refs Versus State for Mutable Values

useRef vs useState in React: refs change without re-rendering, state drives the UI. Learn which values belong where, with timer and DOM examples.

~6 min readread →
esc