React Hooks & Rendering
React Hooks & Rendering

useState & functional updates

Full · ~25 min
After this unit you can
  • Predict what repeated setState calls in one handler produce, given state is a per-render snapshot
  • Choose the functional updater form whenever the next state is computed from the previous state
  • Fix stacked updates that lose increments by rewriting setX(x + 1) as setX(prev => prev + 1)
  • Write a lazy initializer, useState(() => compute()), so expensive setup runs only on the first render

Read

Simple
1 / 2

Use the functional updater when new state depends on old

Flag

useState's setter accepts either a value or a function. When the next value is computed from the previous one, pass a function — it receives the latest state.

// ❌ reads a possibly-stale snapshot
setCount(count + 1);

// ✅ receives the latest value
setCount((c) => c + 1);

Rule of thumb: if the new value mentions the old value, use setX((prev) => ...).

Medium

State is a snapshot; batching only decides how many renders

Flag

Inside an event handler, count is the value from the render that created the handler — a snapshot. Calling the setter doesn't change that variable; it queues an update for the next render.

function handleClick() {
  setCount(count + 1); // count is 0 here: queues "replace with 1"
  setCount(count + 1); // ...and still 0 here: queues "replace with 1" again
}
// next render: count = 1, not 2

The 1 comes from the snapshot: both calls computed 0 + 1. Batching is a separate fact. React waits until the handler has finished and then applies every queued update in a single re-render (since React 18, updates in timeouts, promises and native event listeners are batched too, not just React event handlers). Batching decides how many times React renders, not which value wins.

To make updates compound, queue a function instead of a value. React calls each one with the result of the update before it:

setCount((c) => c + 1); // 0 → 1
setCount((c) => c + 1); // 1 → 2

Complex

Stale state in async callbacks

Flag

A function created during a render sees that render's state, for as long as it lives. If it runs later — after an await, in a timeout, from a subscription — the value it reads may be several updates old.

function SaveButton() {
  const [saves, setSaves] = useState(0);

  async function handleSave() {
    await api.save();
    setSaves(saves + 1); // ❌ `saves` is from the render that handled the click
  }

  return <button onClick={handleSave}>Saved {saves} times</button>;
}

Click twice before the first save returns. Nothing has re-rendered between the clicks, so both run the same handler from the same render, both read saves = 0, and both set 1. Two saves, and the counter says 1.

The updater form doesn't read the closure at all. React calls it with the latest queued value:

setSaves((s) => s + 1); // ✅ 0 → 1 → 2

Quiz

count starts at 0. One click handler calls setCount(count + 1) three times. What is count on the next render, and why?

Flag

When should you use the functional updater form, setX(prev => ...)?

Flag

A component declares const [todos, setTodos] = useState(createInitialTodos()). How often does createInitialTodos run?

Flag

Fill in the blank

Rewrite this increment so three calls in a row reliably add 3, not 1.

Flag
setCount();

Make createInitialTodos run only on the first render, not on every render.

Flag
const [todos, setTodos] = useState();

Predict the output

function Counter() {
  const [count, setCount] = useState(0);
  function handleClick() {
    setCount(count + 1);
    console.log(count);
  }
  return <button onClick={handleClick}>{count}</button>;
}
Flag
function Counter() {
  const [count, setCount] = useState(0);
  function handleClick() {
    setCount(count + 5);
    setCount((c) => c + 1);
  }
  return <button onClick={handleClick}>{count}</button>;
}
Flag

Challenge

Fix the counter that loses updates

Flag

Counter has two buttons, and both lose updates.

Add 3 calls setCount(count + 1) three times — but count is a fixed snapshot for that render (0), so all three calls queue the same update: "set to 1". React applies the three in one re-render (that's batching), and the count lands on 1.

Save +1 waits for a pretend server call, then adds 1. Click it twice before the first save comes back and the count goes up by 1, not 2.

Your task

Edit Counter.tsx so the tests pass:

  • Clicking Add 3 increases the count by 3
  • Two clicks reach 6
  • Two quick Save +1 clicks add 2 once both saves finish
  • The counter still starts at 0

Loading the in-browser editor…

Further reading