Skip to content

Manage events and interface state in React

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.

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.
  • 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-components branch, the typed component tree, WorkStatus, WorkItem, array find, filter, and map, 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.

You have completed the lesson when:

  • the work continues from the clean typed-components commit on feature/react-components;
  • App stores the current WorkItem[], selected WorkFilter, and last action message as three distinct state values;
  • WorkItemCard renders a native button with a visible, status-specific action and a more specific accessible name;
  • activating a card button calls one typed onStatusChange callback with the stable item ID and the next supported status;
  • WorkItemList passes the callback from App to each card without owning a second copy of the work items;
  • App replaces the changed record with a functional setItems update, 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 select stores 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, and npm run build before one focused lesson commit.

Run these commands from the project root:

Verify the component baseline
git switch feature/react-components
git status
git log -1 --oneline
npm ci
npm run typecheck
npm run lint
npm run build

Confirm 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.

The Level 2 task tracker used an explicit cycle:

  1. A browser event ran an event handler.
  2. The handler changed the state object.
  3. The handler called the render function.
  4. 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:

  1. A browser event runs a handler passed through JSX.
  2. The handler calls a React state setter with the next value or an updater function.
  3. React schedules a render of the state-owning component.
  4. The component functions calculate JSX from the new state and props.
  5. 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.

A normal local variable does not provide React state:

A local variable cannot retain an interaction result
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:

State retains a value and provides a setter
const [filter, setFilter] = useState<WorkFilter>("all");
  • filter is the value for the current render.
  • setFilter requests a later render with a replacement value.
  • WorkFilter constrains the stored values.
  • "all" is the initial value on the first render.

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.

Keep Hook calls unconditional and ordered
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.

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:

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.

The child translates a click into a project action
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:

Incorrect — this calls the callback during render
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.

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.

Replace src/components/WorkItemList.tsx:

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.

Temporarily add this handler inside App, before its return:

Temporary callback trace
function handleStatusChange(itemId: string, nextStatus: WorkStatus) {
console.log({ itemId, nextStatus });
}

Import WorkStatus as a type, then update the list call:

Temporary WorkItemList 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:

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.

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.

The setter requests the next render
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:

Replace the matching record without mutation
setItems((currentItems) =>
currentItems.map((item) =>
item.id === itemId
? { ...item, status: nextStatus }
: item,
),
);

Read the update from the inside out:

  1. React supplies the latest queued currentItems value to the updater.
  2. map creates a new array.
  3. Unchanged records keep their existing object identity.
  4. The matching record becomes a new object through object spread.
  5. The later status property replaces the previous status in that new object.
  6. 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.

This version is incorrect:

Incorrect — mutation keeps competing ownership
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.

visibleItems, doneCount, and emptyMessage are normal constants calculated each time App renders:

Derived render values
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)} shown describes the current filter result.
  • {doneCount} of {items.length} done describes the complete board.

After a status change, one state update causes both pieces of output to recalculate from the same source.

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-001 to Active; or
  • the page loaded with WI-001 already 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.

The select is a controlled component because React state supplies its value, and onChange requests the next state:

Controlled filter select
<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:

Narrow the runtime string
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:

Create and connect the feedback reference
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:

Move focus to stable feedback when needed
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.

Add .board-controls to the existing surface selector:

src/styles.scss — shared surfaces
.product-header,
.board-controls,
.work-item {
@include surface;
padding: $space-8;
}

Add these interaction rules after the collection styles:

src/styles.scss — controls and actions
.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:

src/styles.scss — narrow surface padding
@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 controls have no surface, confirm that .board-controls is in the same selector that includes @include surface.
  • If a select or button loses its focus indicator, inspect later selectors for outline: none or 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.

Run the development server and begin from a browser reload before each sequence. Reloading resets the in-memory state to the fixture.

Keep All statuses selected.

  1. Tab to Start work: Prepare the accessible navigation review.
  2. Press Enter.
  3. Confirm that its visible status becomes Active.
  4. Confirm that its button becomes Mark done.
  5. Confirm that the message names the item and says it changed to Active.
  6. Confirm that the same card button retains focus.
  7. Press Space.
  8. 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.

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.

  1. Tab to the only card’s Start work button.
  2. Press Enter.
  3. Confirm that the card leaves the filtered result.
  4. Confirm that No planned work items match this filter. appears.
  5. Confirm that the message says the named item changed to Active.
  6. Confirm that focus is on the visible status message, not body and not a removed element.
  7. 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.

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.

Temporarily remove the arrow function from WorkItemCard:

Temporary incorrect handler
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.

Temporarily return the existing matching object from the updater:

Temporary no-change update
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.

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.

Run the complete source checks:

Verify the interactive client source
npm run typecheck
npm run lint
npm run build

Start the production preview after the commands pass:

Preview the production interaction
npm run preview

Verify 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:

Inspect the state lesson change
git status --short
git diff

Only these source files should change:

  • src/App.tsx;
  • src/components/WorkItemCard.tsx;
  • src/components/WorkItemList.tsx; and
  • src/styles.scss.

Stage and review the exact paths:

Commit the verified state result
git add src/App.tsx src/components/WorkItemCard.tsx src/components/WorkItemList.tsx src/styles.scss
git diff --cached
git commit -m "feat: add work-item state and filtering"
git status

The 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.

  1. Confirm that the lesson started from the clean typed-components commit on feature/react-components.
  2. Point to the three stored state values and explain why each value must persist between renders.
  3. Point to visibleItems, doneCount, emptyMessage, nextStatus, and display labels and explain why none is separate state.
  4. Confirm that every Hook is called unconditionally at the top level of App.
  5. Trace one activation from the native button through onStatusChange props to App and name both callback parameters.
  6. Remove the arrow function from onClick, confirm that type checking rejects void as a handler, restore it, and confirm that type checking passes.
  7. Point to the functional state updater, map, stable ID comparison, object spread, and unchanged-record branch.
  8. Confirm that the updater does not mutate state or perform a side effect.
  9. Use All statuses and confirm that one card changes through Planned, Active, Done, and Planned with correct button labels.
  10. Confirm that a status update recalculates the complete done count without a separate count setter.
  11. Use every filter and confirm the expected cards, shown count, and complete-board count.
  12. Point to the ChangeEvent type and isWorkFilter guard and explain why the DOM value begins as a string.
  13. Confirm that no type assertion bypasses the filter runtime check.
  14. Change a card in All statuses and confirm that its stable action button retains keyboard focus.
  15. Change the only card inside Planned and Active filters and confirm that focus moves to the updated live message.
  16. Confirm that the empty result describes the selected filter and that reload restores the original fixtures.
  17. Verify native control semantics, accessible names, visible status text, focus outlines, 44-pixel control height, and reduced-motion behavior.
  18. Verify the production result at 320 CSS pixels and 200 percent zoom with no page overflow or Console error.
  19. 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:

  1. App passes current data and callbacks down through props.
  2. A card converts a browser click into a typed project action.
  3. The callback travels up to the state owner.
  4. App calculates replacement state without mutation.
  5. React renders the component tree from the next state snapshot.
  6. 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.

The required lesson is complete before these routes. Keep each selected route separate from the required commit.

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.