useState & Updates
The basics, precisely
Section titled “The basics, precisely”useState returns a pair: the current value for this render, and a setter that schedules a re-render with a new value.
const [count, setCount] = useState(0);// count → the value for THIS render (a snapshot)// setCount(next) → schedule a re-render where count is `next`Calling the setter does not change count immediately — it asks React to render again. In the next render, useState(0) returns the updated value. (The Foundations “state is a snapshot” lesson is the mental model; this lesson is the mechanics.)
Batching: multiple setStates, one render
Section titled “Batching: multiple setStates, one render”React batches state updates that happen in the same event. Several setter calls in one handler produce a single re-render, not one per call.
function handleClick() { setCount(c => c + 1); setFlag(true); setName("Ada"); // → ONE re-render, with all three updates applied. React batches them.}Since React 18 this batching also covers updates inside promises, timeouts, and native event handlers — batching is automatic everywhere. Fewer renders, consistent UI.
Proof: batching survives an await
Section titled “Proof: batching survives an await”“Automatic everywhere” includes state updates that happen after an await — not just updates issued synchronously inside a handler. That’s easy to take on faith and hard to picture, so here it is with a render counter:
import { useState } from 'react';
function Counter({ onRender }: { onRender: () => void }) { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); onRender();
async function handleClickAsync() { await Promise.resolve(); // yield once // Both of these happen AFTER an await, outside any React event handler // in the traditional sense — React 18+ still batches them into ONE render. setCount((c) => c + 1); setFlag((f) => !f); }
return ( <button onClick={() => { void handleClickAsync(); }}> {count}-{String(flag)} </button> );}
export default Counter;import { useState } from 'react';import { act, render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Counter({ onRender }: { onRender: () => void }) { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); onRender();
async function handleClickAsync() { await Promise.resolve(); // yield once setCount((c) => c + 1); setFlag((f) => !f); }
return ( <button onClick={() => { void handleClickAsync(); }} > {count}-{String(flag)} </button> );}
test('two setState calls after an await are batched into a single render (React 18+)', async () => { let renders = 0; const { getByRole } = render(<Counter onRender={() => renders++} />); const baseline = renders; // includes StrictMode's extra dev render, if any
await act(async () => { getByRole('button').click(); });
expect(getByRole('button').textContent).toBe('1-true'); // Exactly one additional render happened for BOTH state updates together, // not one render per setState call. expect(renders - baseline).toBe(1);});✓ two setState calls after an await are batched into a single render (React 18+)
Test Files 1 passed (1) Tests 1 passed (1)If React only batched synchronous updates inside the handler (the pre-18 behavior), this test would see 2 renders after the click, not 1. The 2022 React 18 release notes describe exactly this change: before React 18, only updates inside React event handlers were batched — updates inside setTimeout, promises, and native event handlers each triggered their own render. Since 18, all of those are batched too.
Updater functions vs values
Section titled “Updater functions vs values”Pass a value when the next state doesn’t depend on the previous. Pass an updater function when it does — React applies updaters in order against the latest pending value.
setCount(count + 1); // value: uses the snapshot; three of these still add 1 totalsetCount(c => c + 1); // updater: uses the pending value; three of these add 3flowchart LR start["pending = 0"] --> u1["c => c + 1 → 1"] u1 --> u2["c => c + 1 → 2"] u2 --> u3["c => c + 1 → 3"] u3 --> done["next render: count = 3"]
Rule of thumb: if you call the same setter more than once in a handler, or the new value is derived from the old, use the updater form.
Proof: Strict Mode calls your updater twice in dev — but commits once
Section titled “Proof: Strict Mode calls your updater twice in dev — but commits once”Development-mode <StrictMode> intentionally calls an updater function twice to help you notice impure updaters (ones with side effects, like logging or mutating something outside their scope). This is easy to misread as “my state incremented by 2” — it did not; only one of the two calls’ results is kept.
import { StrictMode, useState } from 'react';import { act, render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Counter({ log }: { log: number[] }) { const [count, setCount] = useState(0); return ( <button onClick={() => { // Updater function — StrictMode (dev only) calls it TWICE to help // you spot impure updaters, but only commits the result once. setCount((c) => { log.push(c); return c + 1; }); }} > {count} </button> );}
test('StrictMode invokes an updater function twice in dev, but only commits once', () => { const log: number[] = []; const { getByRole } = render( <StrictMode> <Counter log={log} /> </StrictMode>, ); act(() => { getByRole('button').click(); }); // Called with the same pending value twice (0, 0) — proves the double // invoke, not a real double-increment. expect(log).toEqual([0, 0]); expect(getByRole('button').textContent).toBe('1');});
test('without StrictMode, the same updater runs exactly once', () => { const log: number[] = []; const { getByRole } = render(<Counter log={log} />); act(() => { getByRole('button').click(); }); expect(log).toEqual([0]); expect(getByRole('button').textContent).toBe('1');});✓ StrictMode invokes an updater function twice in dev, but only commits once✓ without StrictMode, the same updater runs exactly once
Test Files 1 passed (1) Tests 2 passed (2)The log proves it: both calls received the pending value 0, not 0 then 1 — React discards the first call’s result and keeps only the second, so the committed count is 1, not 2. This is exactly why an updater function must be pure (no logging side effects in real code, no mutating anything outside itself) — Strict Mode’s double-invoke exists specifically to surface updaters that aren’t.
Lazy initialization
Section titled “Lazy initialization”The argument to useState is only used on the first render, but React still evaluates it every render (then ignores it). If computing the initial value is expensive, pass a function — React calls it only once.
// ❌ createInitialTodos() runs on EVERY render (result discarded after the first).const [todos, setTodos] = useState(createInitialTodos());
// ✅ Lazy: the function runs only on the first render.const [todos, setTodos] = useState(() => createInitialTodos());State must stay immutable
Section titled “State must stay immutable”React decides whether to re-render by comparing the reference. Mutating an object or array in place keeps the same reference, so React may skip the update — and it corrupts the snapshot model. Always produce a new value.
// ❌ Mutation — same reference, unreliable render.user.name = "Ada";setUser(user);
// ✅ New object.setUser({ ...user, name: "Ada" });
// ✅ New arrays for add / remove / update.setItems([...items, newItem]); // addsetItems(items.filter(i => i.id !== id)); // removesetItems(items.map(i => i.id === id ? { ...i, done: true } : i)); // updateFor deeply nested state, spreading gets tedious — that’s a signal to flatten the shape, use an updater library like Immer, or reach for useReducer (a later lesson).
Mental Model
Section titled “Mental Model”Think of every setCount(...) call — value or updater — as pushing an item onto a queue for this render cycle, not as an immediate assignment. React processes that whole queue once, in order, right before the next render: value-form entries replace the pending value outright, updater-form entries transform whatever’s currently pending. That’s why three updaters chain to +3 while three values collapse to +1 — and why “batching” is really just “the queue gets flushed once,” whether the calls happened synchronously in a handler or after an await.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Updater function setX(x => ...) | Always correct when calling the same setter multiple times, or basing the next value on the previous one | Must be pure — Strict Mode calls it twice in dev to enforce that |
Value form setX(next) | Simpler to read for a single, independent update | Silently wrong if called more than once per render with a stale closed-over value |
Lazy init useState(() => expensive()) | The expensive computation runs once, ever | One extra function wrapper to remember to add |
| Mutating state directly | Looks like it “just works” sometimes | React may skip the re-render entirely, and the bug is invisible until it isn’t |
Common Misconceptions
Section titled “Common Misconceptions”- “
setCount(count + 1)called three times adds 3.” It adds 1 — all three reads see the samecountsnapshot from this render. Proven below with a failing/passing pair. - “Strict Mode’s double-invoke means my state changes twice.” It means your updater function runs twice to check for purity; only one result is committed. The visible state change is still exactly one step.
- “Batching only happens inside
onClickand friends.” Since React 18, it happens afterawait, insidesetTimeout, and inside promise callbacks too — proven above. - “Mutating an object and calling the setter with the same reference will still just work, it’s just against best practice.” It can silently fail to re-render, because React’s bail-out check is a reference comparison — this is a real correctness bug, not a style issue.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated component calls a useState setter, check for these before trusting it:
- Is the same setter called more than once in one function, using the value form (
setX(x + 1)) instead of the updater form? That’s a real bug, not a style choice. - Does an updater function passed to
setXhave side effects (logging, network calls, mutating an outer variable)? Strict Mode will call it twice in development and expose the bug. - Is state derived from another state/prop being updated inside an effect, when it could be computed during render instead (see the
useEffectlesson)? - Is an object or array being mutated in place (
.push, direct property assignment) before being passed to the setter, instead of spreading into a new reference? - Does code that updates state after an
awaitassume it needs manual batching help (e.g., wrapping it inunstable_batchedUpdates)? Since React 18 that’s unnecessary — plain calls already batch.
🐛 Spot the bug
function Counter() {const [count, setCount] = useState(0);return ( <button onClick={() => { setCount(count + 1); setCount(count + 1); setCount(count + 1); }} > {count} </button>);}Show the answer
All three calls read count from the same render’s snapshot — clicking once does not add 3, it adds 1, because count + 1 is computed three times against the identical starting value before any of them commits. Proven in the probe: this exact component reaches '1' after one click, not '3'. The fix is the updater form, setCount(c => c + 1), three times in a row — each one reads the pending value left by the previous call in the same batch, so they correctly chain to +3.