10.
Understanding State Updates
Written by Eli Ganim
In the previous chapter, you built search and a full form, following rules this book handed you on faith: Always spread, never assign; derive, don’t store. The Learning Tracker works — but “works” and “understood” are different achievements.
This chapter closes that gap. You’ll watch React batch three updates into one, see exactly why mutation breaks rendering — complete with a ghost course haunting your catalog — and practice adding, removing and updating list state the immutable way.
It’s the most conceptual chapter of Part II, but everything happens in running code, and it ends with a bug hunt: three planted state mistakes for you to diagnose with your new tools. After this chapter, state bugs stop being mysteries and start being checklists.
Building a Click Lab
Some experiments deserve a lab bench rather than production code. Build a temporary one — you’ll demolish it within a few pages. In src/App.tsx, add this small component above App, below the levelFilter line:
function ClickLab() {
const [count, setCount] = useState(0)
console.log('ClickLab renders with count', count)
function handleTripleClick() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}
return (
<button type="button" onClick={handleTripleClick}>
Count: {count}
</button>
)
}
A counter button with an attitude: Every click calls the setter three times. The console.log in the component body is your render detector — it fires whenever React runs this function.
Render the lab at the top of App’s JSX, directly below the opening <main>:
<ClickLab />
Save and check the browser — an unstyled counter button sits above the catalog, and the console already shows the detector line twice:
That doubling is deliberate — it’s Strict Mode running your component twice in development, a safety check this chapter’s last section explains properly. For now, adopt the counting rule: one render = one pair of log lines.
Now click the button once. Three setter calls, so the count should jump to 3 — but the button says Count: 1, and the console adds exactly one pair:
ClickLab renders with count 1
ClickLab renders with count 1
Two surprises for the price of one click: The count moved by one, not three — and three state updates produced a single render.
State Lives in Snapshots
Both surprises come from the same machinery, and it’s the machinery from Chapter 8’s snapshot rule, now seen end to end:
Inside your handler, count isn’t a live connection to React’s memory — it’s the snapshot value from the render that created the handler: 0. So all three calls compute 0 + 1 and issue the same instruction: “make it 1.” Three identical requests, one result.
And React doesn’t re-render after each call. It batches: Every state update triggered by one event is queued, then applied together — a single render pass and a single commit. Render recalculates your components with the new snapshot; commit applies the resulting DOM changes, the split you first met in Chapter 2.
That’s why exactly one pair of log lines appeared, and it’s why sprinkling several setters through one handler is fine: The user never sees half-updated frames.
Updating From the Latest Value
Sometimes a handler genuinely needs “whatever the latest value is, plus one” — three of those should make three. React’s setters accept a function for exactly this. In ClickLab, change the three calls:
setCount((current) => current + 1)
setCount((current) => current + 1)
setCount((current) => current + 1)
A functional updater hands the setter a recipe instead of a value. When React applies the queue, it feeds each recipe the latest queued result — the first receives 0 and returns 1, the second receives 1, the third receives 2.
Save and click once:
Count: 3, with one pair of log lines — batching intact, staleness gone. The rule of thumb: When next state depends on current state, use the updater form.
Your real code has two such places. In src/components/CourseCard.tsx, upgrade the toggle:
function handleFavoriteClick() {
setIsFavorite((current) => !current)
}
“Flip whatever it is now” — the toggle’s true meaning, immune to stale snapshots. And in src/App.tsx, upgrade the add handler:
function handleAddCourse(newCourse: Course) {
setCourses((current) => [...current, newCourse])
}
Same upgrade for the catalog: Append to whatever the latest list is. The lab has served its purpose — delete the ClickLab function and its <ClickLab /> line, then save and confirm the page is back to normal, favorite toggles and course-adding intact.
Why Mutation Breaks React
Time to answer Chapter 9’s IOU: Why spread instead of assignment? Break the rule on purpose and meet the consequences. In src/App.tsx, sabotage the add handler:
function handleAddCourse(newCourse: Course) {
courses.push(newCourse)
setCourses(courses)
}
push adds the course by mutating the existing array — modifying it in place — and then the setter reports the same array back to React. Save, scroll to the form, add a course called “Ghost Course” with any description and submit:
The success message appears — the form’s own state changed properly, so the form re-rendered — but the catalog above shows no new card. Now type a single letter into the search box and delete it again. There’s Ghost Course, materializing a render too late.
Here’s the mechanism. When you call a setter, React compares the new value with the current one using Object.is — for objects and arrays, that’s a same-object-in-memory check. Your mutated array is the same array, so React concludes nothing changed and skips the render entirely.
The pushed course sat invisible inside state until your keystroke forced a render for unrelated reasons — and the ghost walked in with it. Mutation bugs are miserable precisely because of this delay: The code showing the symptom is innocent. Restore the functional-updater version of handleAddCourse, save, and confirm new courses appear immediately again.
Objects break the same way. In src/components/AddCourseForm.tsx, sabotage the title handler:
function handleTitleChange(
event: ChangeEvent<HTMLInputElement>,
) {
form.title = event.target.value
setForm(form)
}
Save and try to type a title. The box is frozen — expect it to stay empty no matter what you press. Every keystroke mutates the object, the setter sees the same reference and skips the render, and the controlled input faithfully redisplays unchanged state. Restore the spread version — setForm({ ...form, title: event.target.value }) — and typing thaws.
Note: The immutable toolkit, in one place. To add: array spread (
[...items, next]) or object spread ({ ...obj, key: value }). To remove from arrays:filter. To replace an item:map, returning an updated copy where the match hits. The mutating classics —push,splice, in-placesortand plain property assignment — stay away from state. New value, new object: That’s the entire contract.
Removing a Course Immutably
You’ve added with spread; now earn the other two tools with a real feature: Users should be able to remove the personal courses they added — and only those. First, teach the Course type who’s personal. In src/types/course.ts, add a line below durationHours:
isPersonal?: boolean
Optional, so the four catalog courses stay valid untouched — absence means “built-in”. Mark new courses at the source. In src/components/AddCourseForm.tsx, extend newCourse below the level line:
isPersonal: true,
Now the removal pipeline, top down. In src/App.tsx, add the handler below handleAddCourse:
function handleRemoveCourse(courseId: string) {
setCourses((current) =>
current.filter((course) => course.id !== courseId),
)
}
There’s filter in its remove role: Keep everything whose id doesn’t match, producing a new, shorter array — the original untouched. Pass it down through the list. In App’s JSX, extend CourseList:
<CourseList
courses={visibleCourses}
emptyMessage={emptyMessage}
onRemoveCourse={handleRemoveCourse}
/>
In src/components/CourseList.tsx, accept and forward it — the props type gains a line:
onRemoveCourse: (courseId: string) => void
Add onRemoveCourse to the destructuring (which now wraps across lines), and pass it into each card:
<CourseCard
key={course.id}
course={course}
onRemove={onRemoveCourse}
/>
Finally, the card. In src/components/CourseCard.tsx, extend the props:
type CourseCardProps = {
course: Course
onRemove: (courseId: string) => void
}
function CourseCard({ course, onRemove }: CourseCardProps) {
And render the escape hatch below the Favorite button, for personal courses only:
{course.isPersonal && (
<Button
label="Remove from catalog"
variant="ghost"
onClick={() => onRemove(course.id)}
/>
)}
One subtlety in that onClick: onRemove needs the course’s id, so you wrap the call in an arrow function. Writing onClick={onRemove(course.id)} would call it during render; the arrow makes a function that calls it on click.
Save, add a course named “My Study Notes” through the form and find its card:
Click Remove from catalog — the card disappears instantly, the counter drops, and the built-in courses remain unremovable. Add and now remove — both immutable.
Updating a Course Immutably
The third tool is map, and a two-minute experiment will fix it in memory — temporary, like the lab. In src/App.tsx, add a handler below handleRemoveCourse:
function handleExciteFirst() {
setCourses((current) =>
current.map((course, index) =>
index === 0
? { ...course, title: course.title + '!' }
: course,
),
)
}
Read it as a sentence: Build a new array where the first course is replaced by an updated copy — object spread plus one changed property — and every other course passes through unchanged. (map‘s second parameter is the item’s index, new here but often handy.) Wire a throwaway button above the SearchBar line:
<button type="button" onClick={handleExciteFirst}>
Excite the first course
</button>
Save and click it a couple of times:
Each click produces a new array with a new first-course object — reference changes all the way up, so React sees it and renders. Delete the handler and the button; the pattern is yours, and Chapter 11 uses it for real when learning-plan items change status.
Storing Less, Deriving More
The second great state skill is refusing to store things. Your App already lives by it — one look at the split:
Only courses and query are state, because only they record what the user did. Everything else — the filtered list, the featured pick, the empty message — is derived: recomputed from state during every render, so it can never disagree with its sources.
Prove the pattern scales by adding one more derived value. In src/App.tsx, add a result counter to the JSX, below the SearchBar line:
<p className="result-count">
Showing {visibleCourses.length} of {courses.length}{' '}
courses
</p>
Two lengths, zero new state. The {' '} is a JSX quirk worth knowing: Line breaks between expressions swallow spaces, so you occasionally hand JSX an explicit one. Style it at the bottom of src/App.css:
.result-count {
margin: 0 0 16px;
font-size: 0.9rem;
color: #57606a;
}
Quiet, small, secondary — information, not decoration. Save and try the search box:
Search, add a course, remove one — the counter never lies. The tempting alternative — a resultCount state updated wherever results change — is duplicate state: a second source of truth waiting to drift. If a value can be computed from existing state, computing it is the implementation.
Making Impossible States Impossible
Duplication has an uglier sibling: contradictory state — independent state variables that can disagree. Your form has a latent case. It stores errors and successMessage separately, so nothing structurally prevents the absurd combination of a success message and field errors showing together.
The cure is modeling the situation as what it really is: one question — “how did the last submit go?” — with exactly three answers. In src/components/AddCourseForm.tsx, add a type below FormErrors:
type SubmitResult =
| { kind: 'idle' }
| { kind: 'invalid'; errors: FormErrors }
| { kind: 'added'; title: string }
Each union arm carries only the data that exists in that situation — errors only when invalid, a title only when added. A contradiction now has no way to exist. Replace the two state lines (errors and successMessage) with one state and one derivation:
const [result, setResult] = useState<SubmitResult>({
kind: 'idle',
})
const errors =
result.kind === 'invalid' ? result.errors : {}
The errors the JSX reads still exists — derived now, empty unless the last submit failed, so not a single field or error line below needs editing. Update handleSubmit’s two outcomes. The failure branch — delete the old setErrors line above hasErrors too — becomes:
if (hasErrors) {
setResult({ kind: 'invalid', errors: nextErrors })
return
}
And the success tail replaces setSuccessMessage(...):
setResult({ kind: 'added', title: newCourse.title })
Storing the title — a record of what just happened, not a copy of live catalog data — keeps the announcement truthful even if the course is later removed. Last, the success paragraph in the JSX becomes:
{result.kind === 'added' && (
<p className="success-note" role="status">
Added "{result.title}" to the catalog.
</p>
)}
Note how TypeScript narrows inside the &&: result.title only exists on the 'added' arm, and that’s the only place you can touch it. Save and run the form through both outcomes — empty submit, then a real one:
Strict Mode’s Double Render
Time to settle the doubled log lines from the lab. That’s Strict Mode — the <StrictMode> wrapper that’s been in main.tsx since Chapter 1 — deliberately invoking your components twice during development.
It’s a bug detector. Rendering is supposed to be a pure calculation, safe to run twice with identical results; code that misbehaves when run twice — mutating something mid-render, say — betrays itself under the doubling. Your components passed all chapter without you noticing, which is the point.
Production builds render once, so the answer to “how do I remove the double log?” is: It isn’t broken, and disabling Strict Mode only blinds the detector. Chapter 12 covers its other development habit — an extra setup-and-cleanup cycle for effects — which is a separate mechanism with the same philosophy.
Challenge: The Three-Bug Hunt
This chapter’s materials include an exercise folder: the Learning Tracker with three state bugs freshly planted. Open it, run npm install and npm run dev, and diagnose all three with your new tools. The symptoms:
- Favorite buttons look right but clicking does nothing.
- The form’s favorites checkbox won’t check.
- The “Showing X of Y courses” line ignores the search filter.
A few hints:
- For each symptom, first decide which component owns the relevant state, then read its setter calls with snapshot eyes.
- One bug is a logic slip inside an updater, one is a mutation, one is stored state that should be derived — three of the chapter’s lessons, in disguise.
- The final folder contains the repaired project if you want to compare answers.
Key Points
- Handlers see their render’s snapshot; every setter call computes from that frozen value, no matter how many times it runs.
- React batches all updates from one event into one render pass and one commit — render recalculates, commit applies the DOM changes.
- Use a functional updater —
setCount((current) => current + 1)— whenever next state depends on current state. - Setters compare old and new with Object.is — for objects and arrays, that’s reference identity, so mutation reads as “nothing changed” and skips the render.
- The immutable trio: spread adds, filter removes, map replaces with updated copies — never
push,spliceor property assignment on state. - Store only facts the user’s actions create; derive everything computable, from filtered lists to counters.
- Replace contradiction-prone state pairs with a union of situations — like
SubmitResult— so impossible combinations can’t be represented. - Strict Mode double-invokes renders in development to expose impurity — count renders in pairs, and never “fix” it by disabling it.
Where to Go From Here?
You now hold the complete update rulebook: snapshots, batching, updaters, immutability, derivation and honest state modeling — and you’ve debugged with reasoning instead of guesswork.
One structural weakness remains, and you’ve known about it since Chapter 8: Every card hoards its favorite privately, so the header can’t count them, and the featured banner can’t react. Chapter 11 fixes it the React way — lifting state to a shared owner — and then builds the feature the app is named for: My Learning, a plan that tracks every course from planned to completed, powered by your first reducer.