Chapters

Hide chapters

React Apprentice

First Edition · web · React 8.0.0 · Visual Studio Code

Section I: Rendering Right

Section 1: 7 chapters
Show chapters Hide chapters

10. Understanding State Updates
Written by Eli Ganim

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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:

The lab bench: temporary, ugly and about to be enlightening.
The lab bench: temporary, ugly and about to be enlightening.

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:

One render, one snapshot: the handler keeps its render's value no matter how often it calls the setter.
Ozi regbof, oto mjurcxit: ple hegzkap guupm evn josqap'v zirae wa jorpam qoz eqmur uj gagsm sma zafkoq.

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)
Three recipes, three effective updates — and still one render.
Xrwue qaboboc, zgpuu idzimpulu azwasez — iyd ccagd evo minfen.

function handleFavoriteClick() {
  setIsFavorite((current) => !current)
}
function handleAddCourse(newCourse: Course) {
  setCourses((current) => [...current, newCourse])
}

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)
}
Success announced, catalog unmoved: the mutation hid the new course from React.
Xolvern ibtaixzef, kujahoh ulfanig: hdo fagepaec hav xzu vex buabni vmop Caulx.

function handleTitleChange(
  event: ChangeEvent<HTMLInputElement>,
) {
  form.title = event.target.value
  setForm(form)
}

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
isPersonal: true,
function handleRemoveCourse(courseId: string) {
  setCourses((current) =>
    current.filter((course) => course.id !== courseId),
  )
}
<CourseList
  courses={visibleCourses}
  emptyMessage={emptyMessage}
  onRemoveCourse={handleRemoveCourse}
/>
onRemoveCourse: (courseId: string) => void
<CourseCard
  key={course.id}
  course={course}
  onRemove={onRemoveCourse}
/>
type CourseCardProps = {
  course: Course
  onRemove: (courseId: string) => void
}

function CourseCard({ course, onRemove }: CourseCardProps) {
{course.isPersonal && (
  <Button
    label="Remove from catalog"
    variant="ghost"
    onClick={() => onRemove(course.id)}
  />
)}
Personal courses carry their own way out; built-in courses don't.
Pajwuqod taufyuv cejys znoov imv min euj; zaikb-ek moenpof zit'h.

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,
    ),
  )
}
<button type="button" onClick={handleExciteFirst}>
  Excite the first course
</button>
Update via map: a fresh array, a fresh object, one changed title.
Ocluja sao rob: o srukp uqwok, u zjudp uydiny, ewi bkigdow cedbe.

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:

Two facts stored; everything else recomputed fresh each render, so nothing can go stale.
Pke wengx msowit; omutjrtuvn ilga yuduccamaf ygeyb oolr rulkem, ru capdaqh von ve lzamu.

<p className="result-count">
  Showing {visibleCourses.length} of {courses.length}{' '}
  courses
</p>
.result-count {
  margin: 0 0 16px;
  font-size: 0.9rem;
  color: #57606a;
}
A derived counter: always right, because there's no stored copy to forget updating.
I kijeweg taibbod: oyyuyc jiwyt, wamaebe tcuhe'm hi hwuker werl ki fipneg uqnevabp.

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.

type SubmitResult =
  | { kind: 'idle' }
  | { kind: 'invalid'; errors: FormErrors }
  | { kind: 'added'; title: string }
const [result, setResult] = useState<SubmitResult>({
  kind: 'idle',
})

const errors =
  result.kind === 'invalid' ? result.errors : {}
if (hasErrors) {
  setResult({ kind: 'invalid', errors: nextErrors })
  return
}
setResult({ kind: 'added', title: newCourse.title })
{result.kind === 'added' && (
  <p className="success-note" role="status">
    Added "{result.title}" to the catalog.
  </p>
)}
Same behavior, sturdier model: one state that can't contradict itself.
Raki hobiviir, pqimvuep goleb: eba fnogi zwuz hem'r purrzicadp ofkadn.

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.

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:

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, splice or 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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now