8.
Events & Local State
Written by Eli Ganim
In the previous chapter, you completed Part I: a typed, styled, accessible course catalog with a folder structure worth showing off. It has exactly one shortcoming — it’s a statue. The “Add to favorites” buttons have been printing console messages since Chapter 4, politely waiting for this moment.
This chapter makes the app react. Two ideas carry the whole of Part II, and you’ll meet both today. Events let the user talk to your components — clicks, presses, typing. State is what lets a component remember the answer and change what it shows. Wire them together and you get the loop every interactive interface runs on: The user acts, state changes, React re-renders, the screen answers.
By the end of the chapter, every course card will own a working favorite toggle — visuals, message and accessibility semantics all driven by state — and you’ll understand the single most important rule of React state: What your component sees is a snapshot, not a live wire.
Renaming the Placeholder
Start from what’s already there. In src/components/CourseCard.tsx, the card has carried this since Chapter 4:
function noteFavoriteClick() {
console.log('Favorites arrive in Chapter 8!')
}
The wiring already works — Button hands the function to React’s onClick prop on a real DOM button, and React calls it whenever the browser reports a click. That function is an event handler, and the only thing wrong with it is the name and the job. Rename it to match the convention you’ll see across the React world — handle plus the event it answers:
function handleFavoriteClick() {
console.log('Favorite clicked!')
}
And update the reference in the JSX below:
onClick={handleFavoriteClick}
Save, check the browser, and click a button with the console open — the new message appears. No visible page change, but the loop’s first half — click to handler — is confirmed working.
Note: A reminder from Chapter 4 that becomes critical now:
onClick={handleFavoriteClick}passes the function itself, as a value, for React to call when the click arrives.onClick={handleFavoriteClick()}calls it immediately — during render — and passes its result. Get this wrong with a state-updating handler, and you’ll trigger an update during every render: an infinite loop with a helpful error. When in doubt: no parentheses.
Trying the Obvious Thing
The handler should flip whether this course is a favorite. The obvious approach: Keep a variable, change it on click. Try it, so you can watch it fail. In CourseCard, add a variable above the handler and use it:
let isFavorite = course.isFavorite
function handleFavoriteClick() {
isFavorite = !isFavorite
console.log('isFavorite is now', isFavorite)
}
Then make the favorite note read from it, changing the condition from course.isFavorite to:
{isFavorite && (
Save and click a favorite button a few times. The console dutifully reports true, false, true — the variable is flipping. The page? Nothing. Not a pixel.
This failure is worth more than most successes, because it exposes how rendering actually works. Your component is a function: React called it, the function returned JSX, and that JSX became pixels.
Changing a local variable afterward changes nothing on screen, because nobody re-ran the function. And even if something did, let isFavorite = course.isFavorite would reset the variable right back. A plain variable has neither of the two powers this job needs: It can’t survive a re-render, and it can’t cause one.
State: The Memory That Triggers Renders
React’s answer is state — the concept Chapter 2 waved at from a distance, finally in your hands. State is data that belongs to a component instance, survives across renders and, when changed through React, schedules a re-render. You create it with useState, the first of React’s hooks — functions starting with use that give components superpowers. They’re callable only at the top level of a component — or of another hook, a possibility Chapter 13 unlocks.
Here’s the full loop you’re about to build:
Every interactive feature in the rest of this book is this diagram with different labels. Time to run it for real.
Making the Favorite Toggle Work
In src/components/CourseCard.tsx, add the import at the top of the file:
import { useState } from 'react'
Hooks live in the react package and arrive as named imports. Then delete the let isFavorite line from the experiment, and in its place — at the top of the component body, above meterWidth — declare state:
const [isFavorite, setIsFavorite] = useState(course.isFavorite)
There’s a lot packed into one line. useState(course.isFavorite) creates a piece of state whose initial value is the course’s flag, and returns two things in an array: the current value and a function that updates it.
The bracket syntax is array destructuring — the sibling of the object destructuring you use for props, unpacking by position instead of by name. Naming the pair something and setSomething is universal convention.
Notice what you didn’t write: no type. TypeScript infers boolean from the initial value, and setIsFavorite will reject anything else.
Now rewrite the handler to update state instead of a dead variable:
function handleFavoriteClick() {
setIsFavorite(!isFavorite)
}
One line, but it’s the line that ends the statue era: Whatever isFavorite is in this render, ask React to make it the opposite. Calling the setter does two things: stores the new value for the next render, and tells React this component needs re-rendering. Now make the button a proper toggle button. Update the Button in the JSX:
<Button
label="Favorite"
onClick={handleFavoriteClick}
pressed={isFavorite}
/>
Two deliberate choices here. The label becomes a stable “Favorite” — a toggle button keeps one name and communicates its on/off state separately, so assistive technology can announce “Favorite, toggle button, pressed” instead of a confusing shape-shifting label. And the state rides in a new pressed prop, which doesn’t exist yet.
Give Button that option. In src/components/Button.tsx, extend the props type:
type ButtonProps = {
label: string
onClick: () => void
variant?: 'primary' | 'ghost'
pressed?: boolean
}
The ? keeps every existing Button caller compiling — toggles opt in, ordinary buttons never mention it. Add pressed, to the destructuring list, and add the attribute to the button element, below onClick:
aria-pressed={pressed}
This is the standard toggle-button contract: aria-pressed="true" or "false" tells screen readers the current state. When a caller omits the optional prop, pressed is undefined — and React drops attributes set to undefined entirely, so ordinary buttons render exactly as before.
Sighted users deserve the state too. Add this to the bottom of src/App.css:
.button[aria-pressed='true'] {
background: #16294d;
border-color: #16294d;
}
.button[aria-pressed='true']::before {
content: '\2605 ';
}
The selector keys the styling off the ARIA attribute itself — one source of truth for screen readers and pixels, so the two can never disagree. A pressed Favorite button darkens and gains a star (\2605 is the star’s character code).
Save and check the browser:
React Basics and Web Accessibility — favorites in the data — show starred, pressed buttons and their notes; the other two show the plain button. Now the moment four chapters in the making: Click React Basics’ button.
The note vanishes, the button releases, and clicking again brings both back. Follow the loop from the diagram: Click → handleFavoriteClick → setIsFavorite(false) → React re-renders CourseCard → the new JSX — computed from the new state — is committed to the page, the applying step you met in Chapter 2. You’ve built your first complete interaction.
Each Card Remembers for Itself
Toggle a few cards into different combinations. Notice that every card keeps its own answer — favoriting CSS Layout doesn’t touch Modern TypeScript. Each rendered <CourseCard /> is a separate instance, and useState gives each instance its own private memory. Same component function, four independent favorites.
Now look at the top of the page in that last screenshot and spot the crack: The banner still says “Featured: React Basics” — computed from course.isFavorite in the data, which your click never touched. The card knows; the banner doesn’t.
Each card’s state is invisible to its parents and siblings. That also means the header can’t show a favorites count, and the featured pick can’t follow your clicks. This isn’t a bug to fix today — it’s the boundary of local state, and moving state to a shared owner is Chapter 11’s whole story.
The Snapshot Rule
One experiment left, and it prevents the most common React bug there is. In handleFavoriteClick, log the state right after setting it:
function handleFavoriteClick() {
setIsFavorite(!isFavorite)
console.log('after the setter:', isFavorite)
}
Open the console and click an unfavorited card’s button. The card flips to favorited — but the console says:
after the setter: false
You just set it to true. The log says false. Nothing is broken; something important is true: State is a snapshot.
When React rendered this card, isFavorite was false, and that value was baked into everything the render produced — the JSX, the note and this handler. Calling the setter doesn’t reach back and rewrite the running function’s variables. It schedules a next render, which will have its own isFavorite, permanently true for that render’s lifetime — your handler always finishes in the world it was born in.
The practical rule: After calling a setter, don’t expect the state variable to have changed — it changes in the next render, not the next line. If a handler needs the new value, it already knows it: It’s the value it just passed to the setter.
Delete the console.log line, then save and check the browser — expect no visible change, since only the logging left.
Note: You may wonder why the handler doesn’t receive any event details. It doesn’t need them — toggling requires nothing about the mouse. When a handler genuinely uses the event object, you’ll type it then; Chapter 9’s form handlers are exactly that case.
Challenge: Add a Details Toggle
Give each card a second piece of state: a details toggle. A ghost button at the bottom of the card — labeled Hide details or Show details depending on state — that shows or hides the description, the duration line and the meter, with details visible by default.
A few hints:
- One new
useState(true), one handler, one derived label — the same anatomy as the favorite state, plus a label that describes the next action, which is why this button doesn’t takepressed. - Move the level badge above the description first, so the three detail elements sit together — then wrap them in a single conditional, remembering what Chapter 3 taught about grouping siblings without adding DOM.
- The
variant="ghost"prop finally gets its moment.
Note: A control that reveals and hides a region is called a disclosure, and its full accessibility pattern uses an attribute named
aria-expandedrather thanaria-pressed. The simple version here — a button whose label says what it will do next — is perfectly accessible; filearia-expandedaway for when you build collapsible sections in your own apps.
You’ll find a complete solution in the challenge folder of this chapter’s materials.
Key Points
- An event handler is a function you pass to props like
onClick— passed without parentheses, called by React when the event fires. - Plain variables can’t power interactivity: They neither survive re-renders nor cause them.
-
useState(initialValue)returns the current value and a setter via array destructuring; TypeScript infers the state type from the initial value. - Calling the setter stores the value for the next render and schedules that render — the loop is click → handler → state update → render → commit, with commit being Chapter 2’s apply-to-the-page phase.
- Derive what you can from state — like
favoriteLabel— instead of storing extra copies. - Each component instance owns its state independently; local state is invisible to parents and siblings.
- A toggle button keeps a stable label and announces its state through aria-pressed — and CSS can style straight off that attribute, keeping pixels and semantics in sync.
- State is a snapshot: After a setter call, the state variable still holds the current render’s value — the new value arrives with the next render.
Where to Go From Here?
The Learning Tracker finally responds to its user — four independent toggles, each running the full event-to-commit loop. You also stubbed your toe, on purpose, against local state’s ceiling: cards that can’t share what they know. Hold that thought for Chapter 11.
Next comes the other great source of user input: typing. Chapter 9 adds a live search box and a full “add your own course” form — controlled inputs, validation, error messages and your first state that holds an object. The catalog stops being read-only.