Manage events and interface state in React
Outcome
Section titled “Outcome”You will turn the static Delivery Board into an interactive React client. A user will filter work items and move each item through its status cycle. React state will remain the single source of truth, visible counts will be derived during rendering, child components will report actions through typed callback props, and focus will move to stable feedback when a filtered item disappears.
Why this matters
Section titled “Why this matters”An interface becomes unreliable when several places store competing versions of the same fact. React state gives a component persistent data and a setter that requests a new render. The design problem is deciding which values belong in state, which values can be calculated, and which component must own each update.
The browser still produces events, arrays still hold records, and the DOM still shows the result. React changes how the program connects those parts. You describe output from the current state instead of writing a separate DOM update for every possible change.
What you will practice
- Trace a React interaction from a browser event through a state setter to the next rendered output.
- Explain why a normal local variable cannot retain interface state or request a React render.
- Declare typed state with useState and follow the top-level Hook rules.
- Pass a typed application event from a child component to the state-owning parent.
- Replace one record in an array with map and object spread instead of mutating existing state.
- Distinguish stored state from values that the component can derive during rendering.
- Treat DOM event values as runtime strings and narrow them before setting union-typed state.
- Preserve an understandable keyboard position when a state update removes the focused card.
What is new and what is reused
Section titled “What is new and what is reused”- New: React events, event-handler props,
useState, Hooks, state setters, render snapshots, functional state updaters, immutable array and object replacement, derived render values, controlled form elements,ChangeEvent,useRef, programmatic focus recovery, and live status feedback. - Reused: The
feature/react-componentsbranch, the typed component tree,WorkStatus,WorkItem, arrayfind,filter, andmap, object spread, type guards, semantic buttons and form controls, responsive Sass, browser DevTools, and the Level 2 event → state → render model.
Starting point
Before you start
- The completed Build typed React components lesson on a clean feature/react-components branch.
- The lesson commit exists, and src/App.tsx renders sampleWorkItems through WorkItemList and WorkItemCard.
- Every work item has a unique stable ID and a supported planned, active, or done status.
- The project passes typecheck, lint, and build before the first state change.
- No router, API request, persistence, or automated component test is required in this lesson.
- Current state
- The component tree can render different props, but the imported fixture remains static. No component stores an interaction result or asks React to render updated work-item data.
- First action
- Open the delivery-board repository, switch to feature/react-components, confirm that the typed-components commit is checked out and the working tree is clean, then run the three baseline checks.
- First checkpoint
- Each WorkItemCard renders a native status-action button and passes a typed item ID and next status through WorkItemList to a temporary App handler.
- Help trigger
- Use the nearest recovery note or ask for help if a state change happens during render, a click handler runs before activation, TypeScript rejects a callback boundary you cannot trace, a changed item does not re-render, a filtered card removes keyboard focus, or a restored final check still fails.
Required result
Section titled “Required result”You have completed the lesson when:
- the work continues from the clean typed-components commit on
feature/react-components; Appstores the currentWorkItem[], selectedWorkFilter, and last action message as three distinct state values;WorkItemCardrenders a nativebuttonwith a visible, status-specific action and a more specific accessible name;- activating a card button calls one typed
onStatusChangecallback with the stable item ID and the next supported status; WorkItemListpasses the callback fromAppto each card without owning a second copy of the work items;Appreplaces the changed record with a functionalsetItemsupdate,map, and object spread;- no handler assigns to an existing item, pushes into the current state array, or passes the same mutated array back to React;
- a controlled native
selectstores All statuses, Planned, Active, or Done as the current filter; - the select handler narrows its runtime string before it calls the filter setter;
- visible items and the done count are derived from the current items and filter during each render rather than stored as duplicate state;
- changing an item in All statuses keeps keyboard focus on the same stable action button;
- changing an item inside a status filter moves focus to the updated status message when the changed card leaves the visible list;
- the live message names the changed work item and its new status;
- filtering to a result with no matches shows a specific visible empty message;
- reloading the page restores the original fixtures because API persistence is outside this lesson;
- mouse, Enter, Space, keyboard Tab, 320-pixel width, 200% zoom, and Console checks pass; and
- the final source passes
npm run typecheck,npm run lint, andnpm run buildbefore one focused lesson commit.
Resume the verified component branch
Section titled “Resume the verified component branch”Run these commands from the project root:
git switch feature/react-componentsgit statusgit log -1 --onelinenpm cinpm run typechecknpm run lintnpm run buildConfirm that the latest commit is the typed-components result and that git status reports a clean working tree. Stay on this branch. The four React lessons form one reviewable client-development sequence for Project stage 2.
If any baseline command fails, stop before you add state. Record the first failure and restore the completed component lesson. State updates will not repair an unresolved import, type, lint, or build problem.
Reuse the Level 2 interaction model
Section titled “Reuse the Level 2 interaction model”The Level 2 task tracker used an explicit cycle:
- A browser event ran an event handler.
- The handler changed the state object.
- The handler called the render function.
- The render function replaced DOM content from the current state.
The React cycle keeps the first two intentions but gives React control of the final render request:
- A browser event runs a handler passed through JSX.
- The handler calls a React state setter with the next value or an updater function.
- React schedules a render of the state-owning component.
- The component functions calculate JSX from the new state and props.
- React commits the required DOM changes.
Do not call render after setItems. The state setter requests the update. Do not select a card and manually change its status text. The card renders the status supplied through its next props.
State is component memory
Section titled “State is component memory”A normal local variable does not provide React state:
function App() { let filter = "all";
function handleFilterChange() { filter = "done"; }
return <p>Current filter: {filter}</p>;}Changing filter does not request another render. If React renders App later, the function runs from the top and creates filter as "all" again.
useState supplies both requirements:
const [filter, setFilter] = useState<WorkFilter>("all");filteris the value for the current render.setFilterrequests a later render with a replacement value.WorkFilterconstrains the stored values."all"is the initial value on the first render.
Hooks run at the top level
Section titled “Hooks run at the top level”useState and useRef are React Hooks. Call Hooks at the top level of a function component. Do not call them inside a condition, loop, nested handler, or normal helper function.
function App() { const [items, setItems] = useState<WorkItem[]>(sampleWorkItems); const [filter, setFilter] = useState<WorkFilter>("all"); const [statusMessage, setStatusMessage] = useState(""); const statusMessageRef = useRef<HTMLParagraphElement>(null);
// Derive values, declare handlers, and return JSX after the Hooks.}React associates each state value with the component through a stable Hook call order. A conditional Hook would change that order between renders.
Decide what the application must remember
Section titled “Decide what the application must remember”Store the smallest complete set of independent interaction facts:
| Value | Store in state? | Reason |
|---|---|---|
| Current work-item records | Yes | Status buttons replace item data over time |
| Selected filter | Yes | The user’s selection must persist between renders |
| Last action message | Yes | The same item data can result from different past actions, so the message cannot be reconstructed reliably |
| Visible work items | No | items and filter determine the result |
| Done count | No | The current items determine the count |
| Next status for one card | No | The current item status determines it |
| Status display label | No | A stable mapping determines it |
Storing visibleItems or doneCount would create another value that every status and filter handler must synchronize. Derive them during rendering so they cannot drift from their source values.
App is the nearest component that needs all three state values. It renders the filter controls and WorkItemList, and it must update the complete collection when any card reports an action. Keep this state local to App; the project does not need a global store.
Add a typed application event to each card
Section titled “Add a typed application event to each card”Replace src/components/WorkItemCard.tsx:
import type { WorkItem, WorkStatus } from "../types/work-item";
export type WorkItemStatusChangeHandler = ( itemId: string, nextStatus: WorkStatus,) => void;
type WorkItemCardProps = { item: WorkItem; onStatusChange: WorkItemStatusChangeHandler;};
const statusLabels: Record<WorkStatus, string> = { planned: "Planned", active: "Active", done: "Done",};
const nextStatusByStatus: Record<WorkStatus, WorkStatus> = { planned: "active", active: "done", done: "planned",};
const actionLabels: Record<WorkStatus, string> = { planned: "Start work", active: "Mark done", done: "Return to planned",};
function formatLabelCount(count: number): string { return `${count} ${count === 1 ? "label" : "labels"}`;}
export function WorkItemCard({ item, onStatusChange,}: WorkItemCardProps) { const nextStatus = nextStatusByStatus[item.status]; const actionLabel = actionLabels[item.status];
return ( <article className="work-item"> <h3>{item.title}</h3> <p>{item.description}</p>
<dl className="work-item-facts"> <div> <dt>Identifier</dt> <dd> <code>{item.id}</code> </dd> </div> <div> <dt>Status</dt> <dd>{statusLabels[item.status]}</dd> </div> </dl>
<h4>Labels</h4> <ul className="label-list"> {item.labels.map((label) => ( <li key={label}>{label}</li> ))} </ul> <p>{formatLabelCount(item.labels.length)} total.</p>
<div className="work-item-actions"> <button className="work-item-action" type="button" aria-label={`${actionLabel}: ${item.title}`} onClick={() => onStatusChange(item.id, nextStatus)} > {actionLabel} </button> </div> </article> );}Keep browser and application events distinct
Section titled “Keep browser and application events distinct”The lowercase browser button receives the built-in React prop onClick. Your custom component receives the application-specific prop onStatusChange.
onClick={() => onStatusChange(item.id, nextStatus)}The arrow function is passed to onClick. React calls it after activation. The function then calls the parent callback with the stable record identity and next domain value.
Do not call the callback while rendering:
onClick={onStatusChange(item.id, nextStatus)}This incorrect expression calls onStatusChange immediately and supplies its void result to onClick. Type checking should reject it, and the behavior would update state during rendering.
Use a native button
Section titled “Use a native button”The visible action label changes with the current status:
| Current status | Button text | Next status |
|---|---|---|
| Planned | Start work | Active |
| Active | Mark done | Done |
| Done | Return to planned | Planned |
button type="button" supplies keyboard focus, Enter and Space activation, and button semantics. Do not replace it with a clickable div.
The aria-label begins with the visible button text and adds the work-item title. This gives repeated buttons a specific accessible name such as Start work: Prepare the accessible navigation review without changing the compact visible label.
Pass the callback through the list
Section titled “Pass the callback through the list”Replace src/components/WorkItemList.tsx:
import type { WorkItem } from "../types/work-item";import { WorkItemCard, type WorkItemStatusChangeHandler,} from "./WorkItemCard";
type WorkItemListProps = { items: readonly WorkItem[]; emptyMessage: string; onStatusChange: WorkItemStatusChangeHandler;};
export function WorkItemList({ items, emptyMessage, onStatusChange,}: WorkItemListProps) { if (items.length === 0) { return ( <section className="work-items"> <h2>Current work</h2> <p>{emptyMessage}</p> </section> ); }
return ( <section className="work-items"> <h2>Current work</h2> <ul className="work-item-list"> {items.map((item) => ( <li key={item.id}> <WorkItemCard item={item} onStatusChange={onStatusChange} /> </li> ))} </ul> </section> );}WorkItemList does not change an item and does not need to know how the application stores state. It forwards the project action to each card. App will supply the actual handler because App owns the collection.
The new emptyMessage prop separates two valid results:
- the complete project has no work items; or
- the project has work items, but none match the selected filter.
The collection component owns where the message appears. App owns which message matches the current state.
Connect the callbacks before state
Section titled “Connect the callbacks before state”Temporarily add this handler inside App, before its return:
function handleStatusChange(itemId: string, nextStatus: WorkStatus) { console.log({ itemId, nextStatus });}Import WorkStatus as a type, then update the list call:
<WorkItemList items={sampleWorkItems} emptyMessage="No work items are available." onStatusChange={handleStatusChange}/>Run the development server, activate each button once, and inspect the Console. The logged ID and status must match the activated card. This temporary trace checks the component event path before state changes the interface.
Remove the temporary console.log when you replace App with the complete state-owning version.
Checkpoint: Card actions reach the future state owner
- What now works
- Each native button reports one stable work-item ID and one supported next status through WorkItemCard and WorkItemList to App, and activation does not run the handler during rendering.
- Files changed
src/components/WorkItemCard.tsx, src/components/WorkItemList.tsx, src/App.tsx, Browser Console- What remains
- Store the collection and filter in App, replace one record immutably, derive visible values, and provide persistent accessible feedback.
- Next action
- Replace the temporary App trace with the complete typed state implementation in the next section.
- If it does not work
- Trace button onClick → card onStatusChange prop → list onStatusChange prop → App handleStatusChange. Confirm that each boundary has the same two parameter types.
Store state and derive the current view in App
Section titled “Store state and derive the current view in App”Replace src/App.tsx:
import { useRef, useState, type ChangeEvent,} from "react";import { WorkItemList } from "./components/WorkItemList";import { sampleWorkItems } from "./data/sample-work-items";import type { WorkItem, WorkStatus } from "./types/work-item";
type WorkFilter = "all" | WorkStatus;
const filterLabels: Record<WorkFilter, string> = { all: "All statuses", planned: "Planned", active: "Active", done: "Done",};
function isWorkFilter(value: string): value is WorkFilter { return ( value === "all" || value === "planned" || value === "active" || value === "done" );}
function formatWorkItemCount(count: number): string { return `${count} work ${count === 1 ? "item" : "items"}`;}
function App() { const [items, setItems] = useState<WorkItem[]>(sampleWorkItems); const [filter, setFilter] = useState<WorkFilter>("all"); const [statusMessage, setStatusMessage] = useState(""); const statusMessageRef = useRef<HTMLParagraphElement>(null);
const visibleItems = filter === "all" ? items : items.filter((item) => item.status === filter);
const doneCount = items.filter( (item) => item.status === "done", ).length;
const emptyMessage = items.length === 0 ? "No work items are available." : `No ${filterLabels[filter].toLowerCase()} work items match this filter.`;
function handleFilterChange(event: ChangeEvent<HTMLSelectElement>) { const nextFilter = event.target.value;
if (!isWorkFilter(nextFilter)) { setStatusMessage("The selected filter is not supported."); return; }
setFilter(nextFilter); }
function handleStatusChange( itemId: string, nextStatus: WorkStatus, ) { const changedItem = items.find((item) => item.id === itemId);
if (!changedItem) { setStatusMessage("The selected work item is no longer available."); statusMessageRef.current?.focus(); return; }
setItems((currentItems) => currentItems.map((item) => item.id === itemId ? { ...item, status: nextStatus } : item, ), );
setStatusMessage( `${changedItem.title} changed to ${filterLabels[nextStatus]}.`, );
if (filter !== "all" && filter !== nextStatus) { statusMessageRef.current?.focus(); } }
return ( <main className="app-shell"> <header className="product-header"> <p className="eyebrow">Module 3.1 project</p> <h1>Delivery Board</h1> <p>Review and update the current work-item status.</p> </header>
<section className="board-controls" aria-labelledby="board-controls-heading" > <h2 id="board-controls-heading">Board controls</h2> <p> {formatWorkItemCount(visibleItems.length)} shown. {doneCount} of{" "} {items.length} done. </p>
<div className="filter-control"> <label htmlFor="status-filter">Show work items</label> <select id="status-filter" value={filter} onChange={handleFilterChange} > <option value="all">All statuses</option> <option value="planned">Planned</option> <option value="active">Active</option> <option value="done">Done</option> </select> </div>
<p ref={statusMessageRef} className="status-message" role="status" aria-atomic="true" tabIndex={-1} > {statusMessage} </p> </section>
<WorkItemList items={visibleItems} emptyMessage={emptyMessage} onStatusChange={handleStatusChange} /> </main> );}
export default App;Read the complete component by responsibility before you test it.
Read state as a render snapshot
Section titled “Read state as a render snapshot”Each call to App receives a snapshot of its state values for that render. The event handlers created during that render read the same snapshot.
Calling a setter does not assign a new value to the current items or filter variable. It requests another render. The new call to App receives the updated value.
setFilter(nextFilter);
// filter still identifies the current render inside this handler.// The next render receives nextFilter.This model explains why logging a state variable immediately after its setter can show the previous value. Inspect the next rendered interface or log inside a later event instead of treating a setter as direct variable assignment.
Use a functional updater for array replacement
Section titled “Use a functional updater for array replacement”The next items value depends on the current items value, so setItems receives an updater function:
setItems((currentItems) => currentItems.map((item) => item.id === itemId ? { ...item, status: nextStatus } : item, ),);Read the update from the inside out:
- React supplies the latest queued
currentItemsvalue to the updater. mapcreates a new array.- Unchanged records keep their existing object identity.
- The matching record becomes a new object through object spread.
- The later
statusproperty replaces the previous status in that new object. - The updater returns the new array to React.
The updater must remain pure. It calculates and returns the next state. Do not set the status message, move focus, log analytics, or perform another side effect inside the updater. React development checks can run updater functions more than once to expose accidental mutation.
Do not mutate the current state
Section titled “Do not mutate the current state”This version is incorrect:
const changedItem = items.find((item) => item.id === itemId);
if (changedItem) { changedItem.status = nextStatus; setItems(items);}It changes an object that belongs to the current render and passes the same array reference back to React. React can skip an update whose state value is the same reference. The mutation also changes data that other code still expects to represent the previous snapshot.
Use non-mutating operations for state arrays:
| Intention | Use | Avoid on current state |
|---|---|---|
| Replace one record | map and object spread |
Property assignment |
| Add one record | Array spread or concat |
push |
| Remove records | filter |
splice |
| Reorder records | Copy first, then sort the copy | sort on the state array |
This lesson replaces one record only. Later code must apply the same ownership rule to additions and removals.
Keep calculated values out of state
Section titled “Keep calculated values out of state”visibleItems, doneCount, and emptyMessage are normal constants calculated each time App renders:
const visibleItems = filter === "all" ? items : items.filter((item) => item.status === filter);
const doneCount = items.filter( (item) => item.status === "done",).length;The filter result depends on items and filter. The done count depends on items. Their calculations are small and bounded, so derive them directly. Do not add an effect that copies either result into another state variable.
The summary intentionally uses both collections:
{formatWorkItemCount(visibleItems.length)} showndescribes the current filter result.{doneCount} of {items.length} donedescribes the complete board.
After a status change, one state update causes both pieces of output to recalculate from the same source.
Keep independent state values separate
Section titled “Keep independent state values separate”The action message is not derived from the current items alone. These two sequences can produce the same final item statuses but need different feedback:
- a user changed
WI-001to Active; or - the page loaded with
WI-001already Active.
Store the last message separately because it represents interaction history. Do not put items, filter, and statusMessage inside one object only because they appear in the same component. Separate setters make each update explicit.
Narrow a native select value at runtime
Section titled “Narrow a native select value at runtime”The select is a controlled component because React state supplies its value, and onChange requests the next state:
<select id="status-filter" value={filter} onChange={handleFilterChange}>The DOM event still reports event.target.value as a general string. TypeScript cannot assume that browser runtime data matches WorkFilter, even when the source contains known options.
The type guard checks the value before the setter receives it:
function isWorkFilter(value: string): value is WorkFilter { return ( value === "all" || value === "planned" || value === "active" || value === "done" );}Do not bypass this boundary with event.target.value as WorkFilter. A type assertion would silence the checker without adding a runtime check.
The guard is small enough to keep near the state type. When the application later receives API JSON, its validator will need to check complete objects rather than one select value.
Preserve focus when filtering removes a card
Section titled “Preserve focus when filtering removes a card”With All statuses selected, changing a card’s status keeps that record in the list. Its stable item.id key helps React match the updated card with its previous output, so the same button can retain focus.
With Planned selected, activating Start work changes the item to Active. That item no longer matches the filter, so its card and focused button leave the DOM. The interface must provide a stable recovery target.
useRef holds a reference to the persistent status-message paragraph:
const statusMessageRef = useRef<HTMLParagraphElement>(null);
<p ref={statusMessageRef} tabIndex={-1} role="status"> {statusMessage}</p>tabIndex={-1} keeps the paragraph out of the normal Tab sequence but permits programmatic focus. The role="status" live region announces changed text without requiring focus. aria-atomic="true" asks assistive technology to present the complete message.
The handler moves focus only when the current filter will remove the changed item:
if (filter !== "all" && filter !== nextStatus) { statusMessageRef.current?.focus();}The paragraph already exists outside the filtered list. React then commits the new message and filtered items while the same paragraph remains focused.
Do not use a delayed timer to guess when rendering finishes. Do not keep a removed card visible only to preserve focus. The stable status region explains the result and gives the keyboard user a known position before the remaining collection.
Checkpoint: State updates drive one predictable interface
- What now works
- App owns the current items, filter, and action message; card callbacks replace one item immutably; visible records and counts derive from state; and filtered status changes recover focus through the persistent live region.
- Files changed
src/App.tsx, src/components/WorkItemList.tsx, src/components/WorkItemCard.tsx- What remains
- Style every interaction state, run deliberate behavior variations, and verify the production result.
- Next action
- Add the control, action-button, focus, and reduced-motion styles from the next section.
- If it does not work
- Return to the last passing component commit, add one state value at a time, and trace event → callback props → App handler → state updater → derived values → rendered output.
Style the controls and visible focus
Section titled “Style the controls and visible focus”Add .board-controls to the existing surface selector:
.product-header,.board-controls,.work-item { @include surface;
padding: $space-8;}Add these interaction rules after the collection styles:
.board-controls { margin-block-start: $space-8;}
.filter-control { display: flex; flex-wrap: wrap; align-items: center; gap: $space-4;}
.filter-control label { font-weight: 700;}
.filter-control select { min-height: 2.75rem; max-width: 100%; border: 1px solid #587064; border-radius: 0.4rem; padding: 0.5rem 2.5rem 0.5rem 0.75rem; color: #17211c; background: #ffffff; font: inherit;}
.status-message { min-height: 1.5em; margin-block-end: 0; overflow-wrap: anywhere;}
.status-message:focus,.filter-control select:focus-visible,.work-item-action:focus-visible { outline: 0.2rem solid #1c6243; outline-offset: 0.2rem;}
.work-item-actions { margin-block-start: $space-6;}
.work-item-action { min-height: 2.75rem; border: 1px solid #154b34; border-radius: 0.4rem; padding: 0.6rem 1rem; color: #ffffff; background: #1c6243; font: inherit; font-weight: 700; cursor: pointer; transition: background-color 0.2s ease, transform 0.2s ease;}
.work-item-action:hover { background: #154b34;}
.work-item-action:active { transform: translateY(0.1rem);}
@media (prefers-reduced-motion: reduce) { .work-item-action { transition: none; }}Add .board-controls to the existing narrow-viewport padding rule:
@media (max-width: 30rem) { .app-shell { margin-block: $space-4; }
.product-header, .board-controls, .work-item { padding: $space-4; }}The native select and button keep a minimum 44-pixel control height. Hover changes only support pointer feedback. Visible text identifies every state and action without color. The focus outline remains visible on the light surfaces, and reduced-motion preference removes the button transition.
If the styles do not behave as expected
Section titled “If the styles do not behave as expected”- If the controls have no surface, confirm that
.board-controlsis in the same selector that includes@include surface. - If a select or button loses its focus indicator, inspect later selectors for
outline: noneor a more specific override. Remove the override. - If the select exceeds a narrow viewport, inspect fixed widths and confirm
max-width: 100%. - If the page scrolls horizontally, inspect the widest card text, grid track, select, and outline before hiding overflow.
- If motion remains under reduced-motion emulation, confirm that the media rule appears after the base transition.
Test the state transitions deliberately
Section titled “Test the state transitions deliberately”Run the development server and begin from a browser reload before each sequence. Reloading resets the in-memory state to the fixture.
Sequence 1: Update a visible card
Section titled “Sequence 1: Update a visible card”Keep All statuses selected.
- Tab to Start work: Prepare the accessible navigation review.
- Press Enter.
- Confirm that its visible status becomes Active.
- Confirm that its button becomes Mark done.
- Confirm that the message names the item and says it changed to Active.
- Confirm that the same card button retains focus.
- Press Space.
- Confirm that its status becomes Done and the complete-board summary changes from 1 of 3 done to 2 of 3 done.
If the card status changes but the summary does not, inspect whether the summary uses items from the current render or a stored count.
Sequence 2: Filter the collection
Section titled “Sequence 2: Filter the collection”Reload, then use the select:
| Filter | Expected cards | Expected summary |
|---|---|---|
| All statuses | 3 | 3 work items shown. 1 of 3 done. |
| Planned | 1 | 1 work item shown. 1 of 3 done. |
| Active | 1 | 1 work item shown. 1 of 3 done. |
| Done | 1 | 1 work item shown. 1 of 3 done. |
The done count remains based on the complete board. The shown count follows the filter.
Sequence 3: Remove the focused card through state
Section titled “Sequence 3: Remove the focused card through state”Reload and select Planned.
- Tab to the only card’s Start work button.
- Press Enter.
- Confirm that the card leaves the filtered result.
- Confirm that No planned work items match this filter. appears.
- Confirm that the message says the named item changed to Active.
- Confirm that focus is on the visible status message, not
bodyand not a removed element. - Press Tab and confirm that focus continues to the next available interactive control in the page structure.
Repeat with Active and Mark done. The result must use the Active-specific empty message and move focus to the same stable status region.
Sequence 4: Confirm the memory boundary
Section titled “Sequence 4: Confirm the memory boundary”Change two statuses and one filter, then reload the page. The original three fixture statuses and All statuses must return.
This reset is expected. React state stores the current mounted client state. It does not write to a database, server, file, or browser storage. API persistence arrives in the server and API project stages.
Deliberately test event-handler syntax
Section titled “Deliberately test event-handler syntax”Temporarily remove the arrow function from WorkItemCard:
onClick={onStatusChange(item.id, nextStatus)}Run npm run typecheck. TypeScript must report that void is not a valid click handler. Restore the arrow function and confirm that type checking passes.
Deliberately test immutable replacement
Section titled “Deliberately test immutable replacement”Temporarily return the existing matching object from the updater:
setItems((currentItems) => currentItems.map((item) => item.id === itemId ? item : item, ),);Type checking still passes because the array remains a valid WorkItem[], but the status does not change. This proves that type correctness does not prove the requested behavior. Restore the object-spread replacement and rerun the interaction.
Do not test mutation by leaving an invalid state in the final source. The submitted updater must create a new array and one new changed object.
Diagnose the first incorrect boundary
Section titled “Diagnose the first incorrect boundary”| Symptom | Likely boundary | Focused check |
|---|---|---|
| Handler runs during page load | Card event binding | Confirm onClick receives a function rather than the callback result |
| Click logs correctly but no status changes | App handler |
Confirm setItems returns a new array with one changed object |
| All cards change | Record match | Confirm the updater compares item.id === itemId |
| Changed status returns immediately | State ownership | Confirm WorkItemList receives items state rather than sampleWorkItems |
| Count disagrees with cards | Derived values | Derive both from the current items and visibleItems |
| Select changes visually but cards do not filter | Controlled filter | Confirm setFilter(nextFilter) runs after the type guard |
| Filter becomes an unsupported value | Runtime boundary | Remove the assertion and restore isWorkFilter |
| Focus moves to the page body | Removal recovery | Confirm the status paragraph has the ref and tabIndex={-1}, then inspect the focus condition |
| Button loses focus in All statuses | Identity | Confirm unique stable item.id keys and that the record remains in the filtered array |
| Status changes twice in development | Impure updater or render | Remove mutation and side effects from the updater and component body |
Fix the first broken boundary before changing later code. A card CSS change cannot repair state ownership, and another setter cannot repair a callback that runs during render.
Checkpoint: The interactive board passes its behavior checks
- What now works
- Native controls update typed state, derived counts remain synchronized, filters show the correct collection, filtered removal recovers focus, reload restores the documented fixture, and deliberate failures behave as predicted.
- Files changed
src/App.tsx, src/components/WorkItemCard.tsx, src/components/WorkItemList.tsx, src/styles.scss, Browser Elements panel, Browser Console- What remains
- Run final source and production checks, inspect the focused diff, and commit the completed state lesson.
- Next action
- Restore the exact final source, run typecheck, lint, and build, then inspect the production preview at normal and narrow widths.
- If it does not work
- Reload the fixture, select All statuses, trace one card action from button to state and output, then repeat the filtered focus test before the final build.
Verify and commit the state result
Section titled “Verify and commit the state result”Run the complete source checks:
npm run typechecknpm run lintnpm run buildStart the production preview after the commands pass:
npm run previewVerify the four behavior sequences again in the production build. Also check:
- normal desktop width;
- 320 CSS pixels;
- 200% browser zoom;
- select operation with pointer and keyboard;
- each action button with pointer, Enter, and Space;
- visible focus on the select, buttons, and programmatically focused status message;
- reduced-motion emulation;
- long work-item title and status-message wrapping;
- no clipped required content or horizontal page scrollbar; and
- no React warning or red application error in the Console.
Stop the preview with Ctrl+C after verification.
Inspect the source diff:
git status --shortgit diffOnly these source files should change:
src/App.tsx;src/components/WorkItemCard.tsx;src/components/WorkItemList.tsx; andsrc/styles.scss.
Stage and review the exact paths:
git add src/App.tsx src/components/WorkItemCard.tsx src/components/WorkItemList.tsx src/styles.scssgit diff --cachedgit commit -m "feat: add work-item state and filtering"git statusThe final status must be clean on feature/react-components. Keep the branch for the routing lesson and Project stage 2 review.
Self-check
Complete these checks against the required result.
- Confirm that the lesson started from the clean typed-components commit on feature/react-components.
- Point to the three stored state values and explain why each value must persist between renders.
- Point to visibleItems, doneCount, emptyMessage, nextStatus, and display labels and explain why none is separate state.
- Confirm that every Hook is called unconditionally at the top level of App.
- Trace one activation from the native button through onStatusChange props to App and name both callback parameters.
- Remove the arrow function from onClick, confirm that type checking rejects void as a handler, restore it, and confirm that type checking passes.
- Point to the functional state updater, map, stable ID comparison, object spread, and unchanged-record branch.
- Confirm that the updater does not mutate state or perform a side effect.
- Use All statuses and confirm that one card changes through Planned, Active, Done, and Planned with correct button labels.
- Confirm that a status update recalculates the complete done count without a separate count setter.
- Use every filter and confirm the expected cards, shown count, and complete-board count.
- Point to the ChangeEvent type and isWorkFilter guard and explain why the DOM value begins as a string.
- Confirm that no type assertion bypasses the filter runtime check.
- Change a card in All statuses and confirm that its stable action button retains keyboard focus.
- Change the only card inside Planned and Active filters and confirm that focus moves to the updated live message.
- Confirm that the empty result describes the selected filter and that reload restores the original fixtures.
- Verify native control semantics, accessible names, visible status text, focus outlines, 44-pixel control height, and reduced-motion behavior.
- Verify the production result at 320 CSS pixels and 200 percent zoom with no page overflow or Console error.
- Run typecheck, lint, and build, inspect the focused diff, commit it, and confirm a clean feature branch.
Explanation: state ownership controls the data flow
Section titled “Explanation: state ownership controls the data flow”The final program has one clear direction:
Apppasses current data and callbacks down through props.- A card converts a browser click into a typed project action.
- The callback travels up to the state owner.
Appcalculates replacement state without mutation.- React renders the component tree from the next state snapshot.
- Derived values recalculate, and React commits the necessary DOM changes.
This direction is more explicit than allowing every card to edit a shared array. It also prepares the API boundary. A later server request can replace the in-memory update, but the card can keep reporting the same application action.
State does not need to live at the highest possible level. It belongs at the nearest common owner of the components that read it or change it. Here, App owns the filter controls and item collection. A card owns no persistent data because all of its visible output comes from its current item prop.
Official references
Section titled “Official references”- Responding to Events — handler functions, callback props, and native controls
- State: A Component’s Memory —
useState, retained values, and renders - Render and Commit — render requests, component evaluation, and DOM commits
- State as a Snapshot — state values inside one render and event handler
- Updating Arrays in State — non-mutating array replacement
- Choosing the State Structure — minimal state and derived values
useRef— stable DOM references for focus recovery
Optional extensions
Section titled “Optional extensions”The required lesson is complete before these routes. Keep each selected route separate from the required commit.
Next step or safe stopping point
Section titled “Next step or safe stopping point”The required result is safely paused when status changes, filters, counts, empty results, messages, and focus recovery work in the production preview; all deliberate failures are restored; the source checks pass; the state commit exists; and git status is clean on feature/react-components.
The next lesson installs React Router. It will turn selected view state into stable URLs while keeping transient action feedback and work-item data in their appropriate owners.