useState & Updater
Batching, lazy init, immutable updates, the updater function.
State is anything a component needs to rememberbetween renders. A counter, a form value, whether a menu is open. Plain variables won't work because a component function runs from the top every time, so any local variable resets. useState is React's answer: give me a value that survives renders, and a function to change it.
The basic shape
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}useState(0) returns a tuple. First slot is the current value. Second slot is a function that schedules a re-render with the new value. Calling setCount doesn't change countin the current render. It tells React: "next time you run this function, use this value instead."
countis just a number that won't change. If you call setCount(count + 1) three times in a row, you set it to count + 1three times. That's still just one. Read on for the fix.The updater function: when state goes stale
Because state is captured per render, doing this is a bug:
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1); // count is still the old number all three times
}Pass a function instead and React calls it with the latest value:
function handleClick() {
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1); // now it really adds 3
}Rule of thumb: if the new state depends on the old state, use the function form. If it doesn't (you're just replacing the value), the direct form is fine and more readable.
Automatic batching
In React 18+, multiple setState calls inside the same event (or promise, or timeout) are batchedinto a single re-render. This is good for performance, but it's also why the bug above is so easy to write.
function handleClick() {
setName("Ada");
setAge(36);
setIsCoder(true);
// ONE re-render, not three. React waits until your handler finishes.
}Lazy initial state
useState(expensiveCalc()) calls expensiveCalc() on everyrender. React ignores the result after the first render, but the call still happens. If it's expensive (parsing large JSON, reading localStorage), pass a function:
// Bad: parseStoredTodos() runs every render
const [todos, setTodos] = useState(parseStoredTodos());
// Good: parseStoredTodos() runs once, the first render only
const [todos, setTodos] = useState(() => parseStoredTodos());Immutable updates for objects and arrays
React decides whether to re-render by comparing the new state to the old with Object.is. If you mutate an object in place, the reference is the same. React sees no change. Nothing renders.
const [user, setUser] = useState({ name: "Ada", age: 36 });
// WRONG: mutates the old object
user.age = 37;
setUser(user); // React: "same object, skip"
// RIGHT: make a new object
setUser({ ...user, age: 37 });
// Same idea for arrays
const [items, setItems] = useState([1, 2, 3]);
// WRONG
items.push(4);
setItems(items);
// RIGHT
setItems([...items, 4]);
setItems(items.filter((i) => i !== 2));
setItems(items.map((i) => i * 2));map, filter, and concat instead of push, splice, and direct index assignment. Or reach for Immer (a library) if your state is deeply nested.Try it: counter and list
Two classic examples. The counter shows the updater function fixing stale state. The list shows immutable array updates. Try removing the spread and see what breaks.
Quiz
You write setCount(count + 1) three times in a click handler. count starts at 0. What is it after the click?
Recap
useStategives you a value + setter that survive renders.- State updates are scheduled, not immediate. The current render sees the old value.
- For state-depends-on-state, use the function form
setX(prev => new). - React batches multiple setters in the same event into one re-render.
- Treat objects and arrays as immutable. Spread,
map,filter. Never mutate. - Lazy initial state with a function avoids re-running expensive setup every render.