A variable does not work
State is two things at once: a value that survives re-renders, and a way to ask for a re-render. A plain variable is neither.
function Counter() {
let count = 0; // reset on every render
return <button onClick={() => count++}>{count}</button>;
// the number changes in memory, and the screen never moves:
// nothing told React to render again
}
useState
- The argument is the initial value, used on the first render only. After that it is ignored.
- The setter does not change
countin the current render —countis a constant for that render. It schedules a new render wherecountis the new value. - If the new value is
Object.is-equal to the old one, React skips the re-render entirely.
const [count, setCount] = React.useState(0);
setCount(5); // set it
setCount(count + 1); // set it from the current render's value
setCount(1); console.log(count); logs the old value. That is not a bug: count belongs to the render it was read in. If you need the new value in the same handler, compute it into a variable first.
Update from the previous value
React batches the updates in a handler and applies them in order. The plain form captures the value from the render it was written in; the updater function receives the value the queue has reached. Use the updater whenever the new value depends on the old one.
// three calls, one increment: all three read the same stale count
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// three calls, three increments: each receives the value so far
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
State is a snapshot
Every render has its own n, its own handlers and its own closures. The timeout captured the n that existed when the click happened, and two seconds later that is still what it holds. This is usually what you want — the code that ran belongs to the state it saw.
function Late() {
const [n, setN] = React.useState(0);
function send() {
setN(n + 1);
setTimeout(() => alert(n), 2000); // alerts the OLD n, always
}
return <button onClick={send}>{n}</button>;
}
Objects and arrays: replace, never mutate
const [door, setDoor] = React.useState({ name: "Painted", days: 5 });
door.days = 10; setDoor(door); // NOTHING HAPPENS: same object
setDoor({ ...door, days: 10 }); // a new object → a re-render
const [list, setList] = React.useState([]);
list.push(item); setList(list); // nothing happens
setList([...list, item]); // add
setList(list.filter((d) => d.id !== id)); // remove
setList(list.map((d) => (d.id === id ? { ...d, days: 9 } : d))); // change one
setList(list.toSorted((a, b) => a.days - b.days)); // sort a copy
{ ...state, door: { ...state.door, days: 9 } }. If you find yourself three levels deep, the state is probably shaped wrong — or it is time for a reducer.
Lazy initial state
// runs on EVERY render, and the result is thrown away after the first
const [rows, setRows] = React.useState(parse(bigString));
// runs once
const [rows, setRows] = React.useState(() => parse(bigString));
What does not belong in state
The test is simple: can I calculate it from what I already have? If yes, calculate it. Two sources of the same truth will disagree, and the bug will be reported as "it shows the old name sometimes".
| Instead of | Do this |
|---|---|
fullName alongside first and last | Compute it during render |
filteredItems alongside items and query | Compute it during render |
isEmpty alongside the list | items.length === 0 |
| A prop copied into state | Use the prop; state will go stale when it changes |
| A value nothing on screen depends on | A ref, or an ordinary variable |
Grouping
Shaping state so that impossible combinations cannot be written down removes a whole class of bug before it exists.
// fine: independent things
const [query, setQuery] = React.useState("");
const [sort, setSort] = React.useState("name");
// better as one: they always change together
const [status, setStatus] = React.useState({ state: "idle", error: null });
// impossible states: these two can both be true
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState(null);
// one field instead: "idle" | "loading" | "error" | "ready"