How it works
The explanation, with examples you can run.
A snapshot per render
import { useState } from "react" export function Counter() { const [count, setCount] = useState(0) function handleClick() { setCount(count + 1) setCount(count + 1) setCount(count + 1) console.log(count) // still 0: this render's value } return <button onClick={handleClick}>{count}</button>}One click shows 1, not 3. count is a const in this call of Counter; every line of handleClick sees 0, so each call queues "set it to 1". This isn't asynchrony: the variable simply belongs to a render that has already happened.
The queue
React keeps the updates in a queue and works through them when it next renders the component. A value replaces the state; a function is called with the result so far. Here is that rule as plain JavaScript (a model of the queue, not React's code):
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
That prints 1, 3 and 6. Use the updater form whenever the next value depends on the previous one and more than one update may be queued.
Batching
React waits until all the code in an event handler has run before it processes the queue, then renders once. Three set calls on two different state variables produce one render, not three, and the user never sees a half-updated screen. Since React 18 the same automatic batching applies to updates from setTimeout, promise callbacks and native event listeners. React doesn't batch across separate events: two clicks are two renders, so a second click sees the first one's result. How the batch is scheduled against the browser's event loop is covered in the event loop topic.
Replace instead of mutating
React decides whether state changed with Object.is(old, new). If you mutate an object or array and pass the same object back, nothing changed by that test and React may skip the render. Treat state as read-only and create new values:
import { useState } from "react" type Item = { id: string; done: boolean } export function useItems(initial: Item[]) { const [items, setItems] = useState(initial) const add = (item: Item) => setItems((prev) => [...prev, item]) const toggle = (id: string) => setItems((prev) => prev.map((it) => (it.id === id ? { ...it, done: !it.done } : it))) return { items, add, toggle }}Only the changed item is a new object; the others keep their identity. That matters later, because memoised children and effect dependencies compare by identity too.
What belongs in state
Keep the minimum. If a value can be computed from props or other state (a filtered list, a full name, a total), compute it during render instead of storing it: a second copy has to be kept in sync, and an effect that syncs it costs an extra render.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.