Course outline · 0% complete

0/28 lessons0%

Course overview →

Events and the state snapshot

lesson 4-2 · ~12 min · 11/28

Events and the state snapshot

Event handlers in React are props like onClick, onChange, and onSubmit, and their value is a function:

<button onClick={handleClick}>Save</button>
<button onClick={() => setCount(count + 1)}>+1</button>

Note you pass the function itself, handleClick, not a call handleClick(). Calling it would run immediately during render, not on click. You met this exact distinction with callbacks in Advanced JavaScript.

Now the subtle part. Look at the destructuring line: const [count, setCount] = useState(0). Within one render, count is a const local variable, assigned once when React called your function. Nothing can reassign it mid-render, and every handler you create during that render closes over that same value. Calling setCount stores a new value for the next render, it cannot rewrite the constant the current render already captured. People describe this by saying each render sees a snapshot of state. So what does this print-style puzzle do?

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

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

If count is 0, all three lines compute 0 + 1. The state ends up 1, not 3.

Functional updates

When the next value depends on the previous one, pass a function to the setter instead of a value:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

React queues the updater functions and runs them in order, each receiving the latest value: 0 → 1 → 2 → 3. Rule of thumb: if the new state is computed from the old state, use the function form.

A miniature of React's setter queue

Two blocks, side by side. The first uses functional updates so each updater sees the latest value, and the second captures a stale snapshot so every updater reuses the old one.

let count = 3;

function setCount(updater) {
  count = updater(count);
}

// functional updates: each sees the latest value
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
console.log(count);

// stale snapshot: every update recomputes from the old value
count = 3;
const stale = count;
setCount(() => stale + 1);
setCount(() => stale + 1);
setCount(() => stale + 1);
console.log(count);

Output

6
4

The first block runs 3 to 4 to 5 to 6, because each updater receives whatever count holds at the moment it runs. The second block sets 4 three times, because stale is 3 forever and 3 + 1 is the answer every time.

This setCount is deliberately crude, calling the updater immediately rather than queueing it, and the arithmetic is identical to React's. What matters is where the previous value comes from, either the argument or a captured variable.

The stale constant plays the role of the count your handler closed over during a render. React's real snapshot behaves exactly like it, which is why three calls to setCount(count + 1) in one handler land on 1 rather than 3.

The rule falls straight out of the comparison. If the new state is computed from the old state, pass a function, and if the new state is a fresh value that does not depend on the old one, passing the value directly is fine and clearer.

Two setter calls from one snapshot

With count at 10, a handler running setCount(count + 5) twice leaves count at 15.

Both calls compute 10 + 5 from the same snapshot, so the second one simply overwrites the first with the same number. Nothing is lost or doubled, and the second call is pure waste.

The function form is what makes the two calls compose, so setCount(c => c + 5) twice gives 20. Each updater receives the result of the one before it, which is the queue behavior from the previous block.

Handler bodyResultWhy
setCount(count + 5) once15one computation from the snapshot
setCount(count + 5) twice15second overwrites with the same value
setCount(c => c + 5) twice20each updater sees the previous result

Note that the value form is not a bug in general. setCount(0) for a reset button and setName(event.target.value) for an input both replace state outright and have no dependence on the previous value, so the function form would only add noise.

A handler that runs during render

<button onClick={reset()}>Reset</button>

The bug is that reset() calls the function during render instead of passing it, and it should read onClick={reset}.

With the parentheses, reset runs immediately while React is rendering, and its return value, probably undefined, becomes the click handler. So the button does nothing when clicked and something happened at the wrong time instead.

If reset calls a setter, this gets worse than useless. The setter schedules a re-render, the re-render calls reset() again, and that schedules another one, producing an infinite render loop that React reports as too many re-renders.

The distinction is the callback rule from Advanced JavaScript, where arr.map(double) passes a function and arr.map(double()) passes its result. Two shapes are correct here and one is not:

  • onClick={reset} passes the function, which is right when no arguments are needed
  • onClick={() => reset(id)} passes a new function that calls yours with an argument
  • onClick={reset()} calls it now, which is the bug

The arrow-function form is worth noting because it looks like the mistake and is not. The arrow is the function being passed, and the call inside it happens only when the arrow runs, which is on click.