React120 min total · 17 parts
React Fundamentals: Components, Hooks, and the Virtual DOM
Part 4 of 17 · ~5 min
State with useState
Props come from outside and the component cannot change them. State is the data a component owns, and changing it is what makes React render again. That second half is the whole reason state exists — it is not just a place to keep values, it is the mechanism by which anything on screen ever changes.
Our filter bar needs the reviewer's search text:
function FilterBar({ onQueryChange }) {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search candidates"
/>
);
}
useState("") returns a pair: the value for this render, and a setter. Calling setQuery does not modify query — it schedules a re-render, and tells React what query should be the next time this component runs.
That distinction matters more than it sounds, so here it is stated bluntly: within a single render, query is a constant. It was created when this render started, it will not change while this render is in progress, and every function defined during this render — every handler, every callback — closed over that specific value. Read it ten times in one pass and you get the same answer ten times.
Hold onto that sentence. It explains the next section, and it explains the worst bug in chapter 7.
Why calling the setter twice doesn't add twice
A reviewer selects three rows and clicks "Reject selected". The handler rejects each one and bumps the reviewed counter:
function rejectSelected(ids) {
ids.forEach((id) => {
rejectOnServer(id);
setReviewedCount(reviewedCount + 1);
});
}
Three candidates get rejected on the server. The counter goes up by one.
Every pass through that loop reads reviewedCount — the constant belonging to this render. If it was 12, all three passes compute 12 + 1 and all three tell React "make it 13". React does what it was told, three times, arriving at 13.
What actually fixes it is switching to the functional updater form — instead of a plain value, you hand the setter a function:
function rejectSelected(ids) {
ids.forEach((id) => {
rejectOnServer(id);
setReviewedCount((count) => count + 1); // count = the latest queued value
});
}
// 12 → 13 → 14 → 15
React queues those three functions and runs them in order when it processes the update, feeding each one the result of the last. The first sees 12, the second sees 13, the third sees 14. Nothing is captured from render time, so nothing can be stale.
Use the functional form whenever the new state depends on the old state. Not as a style rule — because the direct form is only correct when you can prove no other update to that value is already queued, and as an application grows you stop being able to prove that.
State updates are batched
Rejecting a candidate touches three pieces of state at once — the list, the counter, and the drawer:
function handleReject(id) {
setCandidates((list) => list.map((c) => (c.id === id ? { ...c, stage: "rejected" } : c)));
setReviewedCount((count) => count + 1);
setDrawerOpen(false);
// exactly ONE re-render, with all three applied
}
React collects the updates made during one event and applies them together. Three setters, one render, one paint — instead of three renders and two intermediate frames where the dashboard is briefly in a state that never really existed.
Before React 18 this only happened inside React's own event handlers; updates inside a setTimeout, a promise callback or a native listener each triggered their own render. React 18 extended batching to all of those, which is called automatic batching. If you ever genuinely need an update applied synchronously before the next line — measuring the DOM immediately after a change is the honest use case — flushSync from react-dom opts out for one update, at the cost of the render it was avoiding.
The practical consequence is the one that catches people: the line right after you call a setter is not a safe place to trust that state's value.
setDrawerOpen(true);
console.log(drawerOpen); // false — still this render's constant
Not because of a race, and not because the update is slow. Because drawerOpen is a constant in this render and the new value belongs to the next one.
Don't mirror props into state
Our review form needs to show whatever notes were saved for the selected candidate, and let the reviewer edit them. The obvious way to do that is wrong:
function ReviewForm({ candidateId, initialNotes }) {
const [notes, setNotes] = useState(initialNotes); // looks reasonable
return <textarea value={notes} onChange={(e) => setNotes(e.target.value)} />;
}
Open Priya's row, read her notes, close it. Open the next candidate's row. Priya's notes are still in the textarea.
The argument to useState is the initial state, and React reads it on the first render of a component instance and never again. ReviewForm did not unmount when the selection changed — it re-rendered with a different candidateId — so as far as React is concerned it is the same instance, and its state is its own business.
You have two correct options, and they answer different questions.
If the component should always mirror the prop, do not copy it at all. Use the prop.
If the component genuinely owns editable state that should reset when the subject changes, tell React that this is conceptually a different form now, by giving it a different key:
<ReviewForm key={candidateId} candidateId={candidateId} initialNotes={notes} />
A changed key makes React discard the old component instance and mount a fresh one, which runs useState(initialNotes) again from scratch. It is not a hack; it is the supported way to reset state from outside, and it works because of exactly the same identity rules that decide which DOM nodes get reused — which is the next chapter.
The general principle underneath: state is for values this component changes over time. A filter query as somebody types, a drawer being open, a counter. Not for restating something that is already being handed to you.