Skip to content

Connect a React application to an API

You will replace the React client’s trusted fixture with the Express API as its runtime data source. The client will request a relative /api URL, parse every successful response from unknown, and render explicit loading, success, empty, and failure results.

Status buttons will send validated PATCH requests. The client will wait for a validated server response before replacing the selected record. While the request is pending, the selected action will be disabled and named as updating. A failed update will keep the previous item, report the failure, and move focus to stable feedback.

The network separates the client and server even when both projects use TypeScript. A response can be missing, delayed, malformed, stale, produced by a different server version, intercepted by a development proxy, or changed outside the current client. The client must treat response JSON as external runtime data.

Loading and failure are not edge decorations. They are normal application states. A usable client tells the user what is happening, preserves accepted data during a failed update, provides a retry path, and does not render unvalidated JSON as trusted application state.

What you will practice

  • Use a relative API URL and a bounded Vite proxy without enabling an unrestricted CORS policy.
  • Distinguish network failure, non-success HTTP response, invalid JSON, and invalid data shape.
  • Parse a collection and one item from unknown through explicit WorkItem checks.
  • Validate IDs, text fields, status values, label values, and collection uniqueness at the client boundary.
  • Model loading, ready, and error as exclusive state variants.
  • Abort or ignore an obsolete initial request during Effect cleanup.
  • Render visible loading, empty, success, and failure results with suitable status or alert semantics.
  • Retry a failed collection request without reloading the complete browser page.
  • Send a JSON PATCH request and replace state only from the validated server representation.
  • Prevent duplicate status submissions and preserve usable focus after success or failure.
  • Inject the API boundary in tests instead of making client behavior tests depend on a live server.
  • Explain why the same runtime validation and response-state model applies to a JavaScript client consuming a PHP API.
  • New: Fetch adapter, relative API URL, Vite proxy, Response.ok, response JSON as unknown, client contract parser, contract error, network error, AbortController, request cleanup, load-state union, retry attempt, pending item ID, pessimistic server update, API dependency injection, and test doubles at the network boundary.
  • Reused: Express routes and error codes, WorkItem, WorkStatus, React state and Effects, React Router routes and focus behavior, accessible status text, filtered-card recovery, Vitest, React Testing Library, user-event, Node API tests, production build, browser checks, feature/express-api, and focused commits.

Starting point

Before you start

  • The validated API commit exists on a clean feature/express-api branch.
  • GET /api/work-items and PATCH /api/work-items/:itemId/status pass automated and manual checks.
  • The Express server listens locally at 127.0.0.1:3000 and remains separate from Vite.
  • The React client still initializes from src/data/sample-work-items.ts and updates its own array.
  • The routed client already has empty collection text, URL filters, status feedback, detail routes, and focus handling.
  • Client and API tests, complete typecheck, lint, build, and current browser checks pass before integration.
  • No API deployment, database, login, token, cookie, secret, service worker, offline cache, global state library, or server-rendered React is required.
Current state
The server and client work independently. The browser never requests the server collection, client updates do not reach Express, and existing React tests assume immediate trusted fixture data.
First action
Verify both accepted processes independently, write the five client response states in README.md, and create src/api/work-items-api.ts before changing App or deleting the client fixture.
First checkpoint
The fetch adapter accepts a valid collection response, rejects one invalid record and duplicate identity, and sends one validated status update through relative /api URLs.
Help trigger
Use the nearest recovery note or ask for help if the Vite origin requests itself instead of Express, CORS appears unexpectedly, response.json is treated as WorkItem without checks, an Effect repeats indefinitely, Strict Mode produces a stale result, retry does not create a new request, a rejected update changes visible state, a disabled action loses its name, existing route tests race the initial load, or production preview and development results differ.

You have completed the lesson when:

  • work continues from the clean validated-API commit on feature/express-api;
  • README defines loading, populated success, empty success, initial failure with retry, update pending, and update failure before implementation;
  • vite.config.ts proxies relative paths beginning with /api to http://127.0.0.1:3000 for development and inherited local preview behavior;
  • the proxy keeps the browser on the Vite origin and does not require an unrestricted server CORS policy;
  • src/api/work-items-api.ts exposes a replaceable WorkItemsApi boundary;
  • every response body starts as unknown and passes explicit JSON and data-shape validation before it enters React state;
  • a work item has exactly id, title, description, status, and labels with the documented value rules;
  • collection parsing rejects duplicate IDs and duplicate labels within one item;
  • the adapter distinguishes aborted, network, HTTP, JSON, and contract failures without exposing raw response data to the page;
  • collection requests use GET /api/work-items with Accept: application/json;
  • status requests use the encoded item ID, PATCH, JSON content type, and { "status": nextStatus };
  • an update response must return the requested ID and status before the client accepts it;
  • App receives a default API implementation but tests can inject a deterministic WorkItemsApi double;
  • the initial request starts in an Effect and aborts its obsolete request during cleanup;
  • loading shows a page heading and live status text without an empty-list claim;
  • successful non-empty data renders the existing routed client behavior;
  • successful empty data renders the existing specific empty result;
  • initial failure shows a page heading, a concise alert, and a native retry button;
  • retry starts a new loading request without reloading the browser page;
  • status updates wait for the server response before replacing the item;
  • all status-update buttons are disabled while one update is pending, and the selected button’s visible and accessible name reports updating;
  • update failure keeps the last accepted item, reports a specific retryable message, and moves focus to stable feedback;
  • successful filtered updates keep the existing filtered-card focus recovery;
  • the client fixture import and source file are removed only after API-backed success works;
  • existing React tests use an injected fake API and await the ready state rather than contacting a live server;
  • added tests cover initial loading, failed load and retry, invalid response rejection, and failed update without local mutation;
  • all client and API tests, typecheck, lint, build, two-process development checks, and two-process preview checks pass; and
  • README records proxy scope, response validation, states, tests, process order, local limits, and recovery before the focused connection commit.

Verify the branch and both independent boundaries:

Verify the API and client baseline
git switch feature/express-api
git status
git log -1 --oneline
npm ci
npm test
npm run typecheck
npm run lint
npm run build

Start npm run start:server, request /api/health and /api/work-items, then stop the server. Preview the client separately and confirm its accepted fixture behavior. Do not connect two unverified results.

Add this state contract to README:

State Visible result Available action Data ownership
Initial loading Loading heading and live text None No accepted collection yet
Populated success Existing routes, filters, cards, counts, and actions Navigate, filter, update Validated API collection in React state
Empty success Specific no-work-items result Navigate and retry only if product adds it later Validated empty API collection
Initial failure Unavailable heading, concise alert, retry button Retry No unvalidated response enters state
Update pending Last accepted item plus disabled selected action Other navigation remains available Previous accepted collection
Update failure Previous item plus focused failure feedback Repeat the item action Previous accepted collection

Do not collapse loading, empty, and failure into one message. They describe different evidence and support different next actions.

Create the client contract and fetch adapter

Section titled “Create the client contract and fetch adapter”

Create src/api/work-items-api.ts:

src/api/work-items-api.ts
import type { WorkItem, WorkStatus } from '../types/work-item';
type Fetcher = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export type WorkItemsApi = {
list(signal?: AbortSignal): Promise<WorkItem[]>;
updateStatus(
itemId: string,
status: WorkStatus,
signal?: AbortSignal,
): Promise<WorkItem>;
};
type ApiClientErrorKind = 'network' | 'http' | 'json' | 'contract';
export class ApiClientError extends Error {
readonly kind: ApiClientErrorKind;
constructor(kind: ApiClientErrorKind, message: string) {
super(message);
this.name = 'ApiClientError';
this.kind = kind;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isWorkStatus(value: unknown): value is WorkStatus {
return value === 'planned' || value === 'active' || value === 'done';
}
function isNonEmptyText(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function parseWorkItem(value: unknown): WorkItem | undefined {
if (!isRecord(value)) {
return undefined;
}
const expectedKeys = ['id', 'title', 'description', 'status', 'labels'];
const keys = Object.keys(value);
if (
keys.length !== expectedKeys.length
|| !expectedKeys.every((key) => Object.hasOwn(value, key))
) {
return undefined;
}
if (
typeof value.id !== 'string'
|| !/^WI-\d{3}$/.test(value.id)
|| !isNonEmptyText(value.title)
|| !isNonEmptyText(value.description)
|| !isWorkStatus(value.status)
|| !Array.isArray(value.labels)
|| !value.labels.every(isNonEmptyText)
|| new Set(value.labels).size !== value.labels.length
) {
return undefined;
}
return {
id: value.id,
title: value.title,
description: value.description,
status: value.status,
labels: [...value.labels],
};
}
function parseCollectionResponse(value: unknown): WorkItem[] {
if (!isRecord(value) || !Array.isArray(value.data)) {
throw new ApiClientError(
'contract',
'The API collection response does not match the client contract.',
);
}
const validItems: WorkItem[] = [];
for (const candidate of value.data) {
const item = parseWorkItem(candidate);
if (item === undefined) {
throw new ApiClientError(
'contract',
'The API returned an invalid work item.',
);
}
validItems.push(item);
}
if (new Set(validItems.map((item) => item.id)).size !== validItems.length) {
throw new ApiClientError(
'contract',
'The API returned duplicate work-item IDs.',
);
}
return validItems;
}
function parseItemResponse(value: unknown): WorkItem {
if (!isRecord(value)) {
throw new ApiClientError(
'contract',
'The API item response does not match the client contract.',
);
}
const item = parseWorkItem(value.data);
if (item === undefined) {
throw new ApiClientError(
'contract',
'The API returned an invalid work item.',
);
}
return item;
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}
async function requestJson(
fetcher: Fetcher,
input: RequestInfo | URL,
init: RequestInit,
): Promise<unknown> {
let response: Response;
try {
response = await fetcher(input, init);
} catch (error) {
if (isAbortError(error)) {
throw error;
}
throw new ApiClientError('network', 'The API could not be reached.');
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new ApiClientError('json', 'The API response was not valid JSON.');
}
if (!response.ok) {
throw new ApiClientError(
'http',
`The API returned HTTP ${response.status}.`,
);
}
return body;
}
export function createWorkItemsApi(
fetcher: Fetcher = (input, init) => fetch(input, init),
): WorkItemsApi {
return {
async list(signal) {
const body = await requestJson(fetcher, '/api/work-items', {
method: 'GET',
headers: { Accept: 'application/json' },
signal,
});
return parseCollectionResponse(body);
},
async updateStatus(itemId, status, signal) {
const body = await requestJson(
fetcher,
`/api/work-items/${encodeURIComponent(itemId)}/status`,
{
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ status }),
signal,
},
);
const item = parseItemResponse(body);
if (item.id !== itemId || item.status !== status) {
throw new ApiClientError(
'contract',
'The API update response does not match the requested change.',
);
}
return item;
},
};
}
export const workItemsApi = createWorkItemsApi();

The adapter owns transport and contract checks. Components receive WorkItem values or a rejected promise. They do not inspect raw Response objects.

response.ok reports whether the HTTP status is in the success range. It does not prove that:

  • the body is JSON;
  • the JSON has a data property;
  • every item has the five required fields;
  • IDs are path-safe and unique;
  • statuses belong to the union;
  • labels are strings and unique; or
  • the update response represents the requested item and status.

The adapter checks all of those rules before returning. It copies label arrays so React state does not retain a mutable array owned by a parsed external object.

The loop appends only values that pass parseWorkItem. No assertion is required to turn the external collection into WorkItem[].

The adapter distinguishes four client-facing failure categories for diagnosis:

  • network: no HTTP response arrived;
  • http: a non-success status arrived;
  • json: the response body could not be decoded as JSON; and
  • contract: decoded JSON did not satisfy the client model.

The page will convert these categories into concise user actions. It will not render raw response bodies, server stacks, or exception objects.

Why the PHP source language does not change this client

Section titled “Why the PHP source language does not change this client”

The React client consumes HTTP, not Express source. If a PHP API returned the same methods, paths, status codes, headers, and JSON shapes, the fetch and validation responsibilities would be the same. Changing the server language does not make browser response JSON trustworthy.

Checkpoint: The client accepts only validated API representations

What now works
Relative collection and update requests distinguish network, HTTP, JSON, and data-contract failures; valid records and updates become copied WorkItem values; invalid identity, fields, status, labels, and duplicates are rejected.
Files changed
src/api/work-items-api.ts, README.md
What remains
Route relative requests to the local Express process and replace fixture initialization with explicit asynchronous states.
Next action
Add the bounded /api proxy to vite.config.ts without changing the Express CORS policy.
If it does not work
Inspect response arrival, response.ok, JSON decoding, envelope shape, item shape, identity rules, and requested update match in that order.

Open vite.config.ts. Keep the React plugin and existing Vitest configuration. Add this server property beside plugins and test:

vite.config.ts — add inside defineConfig
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: false,
},
},
},

The browser requests /api/work-items from the Vite origin. Vite forwards matching local requests to Express. The path stays /api/work-items, so the client does not need a machine-specific base URL.

Vite’s preview proxy defaults to the development proxy configuration. This lets the local production-assets preview reach the same local Express process for verification. Vite preview is still not a production server or deployment design.

Do not set server.cors to true, add Access-Control-Allow-Origin: *, or expose Express on 0.0.0.0. The relative request and local proxy keep the browser-facing request on one origin for this course workflow.

Use two terminals:

Terminal 1 — start Express
npm run dev:server
Terminal 2 — start Vite
npm run dev

Open the exact Vite URL. In the browser address bar, append /api/health to the Vite origin. The response must come from Express through Vite and contain the health JSON.

If Vite returns the client HTML, confirm the proxy key starts with /api and that vite.config.ts was saved before the Vite process started. If the proxy reports connection refused, confirm Express is still running on 127.0.0.1:3000.

Replace fixture initialization with explicit load state

Section titled “Replace fixture initialization with explicit load state”

Replace src/App.tsx with this API-backed version. It preserves the existing header, navigation, route table, document-title behavior, and route-heading focus:

src/App.tsx
import {
useEffect,
useRef,
useState,
} from 'react';
import {
Link,
NavLink,
Route,
Routes,
useLocation,
} from 'react-router';
import {
ApiClientError,
type WorkItemsApi,
workItemsApi,
} from './api/work-items-api';
import type { WorkItemStatusChangeHandler } from './components/WorkItemCard';
import { BoardPage } from './pages/BoardPage';
import { NotFoundPage } from './pages/NotFoundPage';
import { StatusGuidePage } from './pages/StatusGuidePage';
import { WorkItemDetailsPage } from './pages/WorkItemDetailsPage';
import type { WorkItem } from './types/work-item';
type LoadState =
| { status: 'loading' }
| { status: 'ready'; items: WorkItem[] }
| { status: 'error'; message: string };
type AppProps = {
api?: WorkItemsApi;
};
type RoutedContentProps = {
loadState: LoadState;
onRetry(): void;
onStatusChange: WorkItemStatusChangeHandler;
};
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}
function getLoadErrorMessage(error: unknown): string {
if (error instanceof ApiClientError && error.kind === 'contract') {
return 'Delivery Board received work-item data it could not use.';
}
return 'Delivery Board could not load work items from the API.';
}
function getNavLinkClassName({ isActive }: { isActive: boolean }) {
return isActive
? 'app-nav-link app-nav-link--active'
: 'app-nav-link';
}
function RoutedContent({
loadState,
onRetry,
onStatusChange,
}: RoutedContentProps) {
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
const previousPathRef = useRef(location.pathname);
useEffect(() => {
const pathChanged = previousPathRef.current !== location.pathname;
previousPathRef.current = location.pathname;
const heading = mainRef.current?.querySelector('h1');
if (!(heading instanceof HTMLHeadingElement)) {
return;
}
const headingText = heading.textContent?.trim() || 'Delivery Board';
document.title = `${headingText} | Delivery Board`;
if (pathChanged) {
heading.focus();
}
}, [location.pathname, loadState.status]);
let content;
if (loadState.status === 'loading') {
content = (
<section aria-labelledby="loading-heading">
<h1 id="loading-heading" tabIndex={-1}>
Loading Delivery Board
</h1>
<p role="status">Loading work items…</p>
</section>
);
} else if (loadState.status === 'error') {
content = (
<section aria-labelledby="error-heading">
<h1 id="error-heading" tabIndex={-1}>
Delivery Board unavailable
</h1>
<p role="alert">{loadState.message}</p>
<button type="button" onClick={onRetry}>
Try again
</button>
</section>
);
} else {
content = (
<Routes>
<Route
path="/"
element={(
<BoardPage
items={loadState.items}
onStatusChange={onStatusChange}
/>
)}
/>
<Route path="/guide" element={<StatusGuidePage />} />
<Route
path="/work-items/:itemId"
element={<WorkItemDetailsPage items={loadState.items} />}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
return (
<main id="main-content" ref={mainRef} className="route-content">
{content}
</main>
);
}
function App({ api = workItemsApi }: AppProps) {
const [loadState, setLoadState] = useState<LoadState>({
status: 'loading',
});
const [loadAttempt, setLoadAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
let isCurrent = true;
async function loadWorkItems() {
setLoadState({ status: 'loading' });
try {
const items = await api.list(controller.signal);
if (!isCurrent) {
return;
}
setLoadState({ status: 'ready', items });
} catch (error) {
if (!isCurrent || isAbortError(error)) {
return;
}
setLoadState({
status: 'error',
message: getLoadErrorMessage(error),
});
}
}
void loadWorkItems();
return () => {
isCurrent = false;
controller.abort();
};
}, [api, loadAttempt]);
const handleStatusChange: WorkItemStatusChangeHandler = async (
itemId,
nextStatus,
) => {
const updatedItem = await api.updateStatus(itemId, nextStatus);
setLoadState((currentState) => {
if (currentState.status !== 'ready') {
return currentState;
}
return {
status: 'ready',
items: currentState.items.map((item) =>
item.id === updatedItem.id ? updatedItem : item,
),
};
});
};
return (
<div className="app-shell">
<header className="product-header">
<p className="eyebrow">Module 3.1 project</p>
<p className="app-name">
<Link to="/">Delivery Board</Link>
</p>
<p>Review and update the current work-item status.</p>
</header>
<nav className="app-nav" aria-label="Primary">
<NavLink className={getNavLinkClassName} to="/" end>
Board
</NavLink>
<NavLink className={getNavLinkClassName} to="/guide">
Status guide
</NavLink>
</nav>
<RoutedContent
loadState={loadState}
onRetry={() => setLoadAttempt((attempt) => attempt + 1)}
onStatusChange={handleStatusChange}
/>
</div>
);
}
export default App;

loading, ready, and error are mutually exclusive. The union prevents combinations such as isLoading: true beside a current error and a stale empty array whose meaning is unclear.

An empty collection is not a fourth transport state. It is a successful ready state with items.length === 0. The existing BoardPage and WorkItemList render its specific result.

The Effect creates one AbortController and one current-attempt flag. Cleanup marks the result obsolete and aborts the request when the component unmounts, the API dependency changes, or retry starts another attempt. An aborted or late obsolete result does not replace current state.

React Strict Mode performs an extra development setup and cleanup cycle. The first request can abort; the current request supplies the visible result. Do not remove Strict Mode to hide a missing cleanup path.

Accept the server representation, not the requested guess

Section titled “Accept the server representation, not the requested guess”

The status handler does not update React state before the request succeeds. It awaits the validated server item and replaces the matching client record with that representation.

This pessimistic update keeps the previous accepted state during the request. It is suitable for the current small local API. An optimistic interface would require rollback, concurrent-update, and stale-response rules that are outside this lesson.

Checkpoint: The routed client has explicit asynchronous page states

What now works
Relative API loading produces one loading, ready, or error result; retry creates a new abortable request; valid empty and populated collections remain distinct; and accepted status state comes from the server representation.
Files changed
vite.config.ts, src/App.tsx, src/api/work-items-api.ts
What remains
Make card actions awaitable and visibly pending, preserve focus and data on failure, then adapt tests to the injected boundary.
Next action
Change WorkItemStatusChangeHandler to Promise<void> and add the selected pending item through BoardPage, WorkItemList, and WorkItemCard.
If it does not work
Check Express process, proxy response, adapter result, Effect attempt, load-state branch, and rendered page result before changing route or component state.

The existing callback returns void. Change the exported type in src/components/WorkItemCard.tsx:

src/components/WorkItemCard.tsx — asynchronous application event
export type WorkItemStatusChangeHandler = (
itemId: string,
nextStatus: WorkStatus,
) => Promise<void>;

Add two props to WorkItemCardProps:

src/components/WorkItemCard.tsx — pending props
type WorkItemCardProps = {
item: WorkItem;
isUpdating: boolean;
statusUpdatesDisabled: boolean;
onStatusChange: WorkItemStatusChangeHandler;
};

Receive the new props and replace the status button:

src/components/WorkItemCard.tsx — pending status action
<button
className="work-item-action"
type="button"
aria-label={
isUpdating
? `Updating status: ${item.title}`
: `${actionLabel}: ${item.title}`
}
disabled={statusUpdatesDisabled}
onClick={() => {
void onStatusChange(item.id, nextStatus);
}}
>
{isUpdating ? 'Updating status…' : actionLabel}
</button>

void makes the deliberate event-boundary decision visible: React does not use the returned promise, while BoardPage owns its success and failure handling. Do not remove the await from that page.

Every status button is disabled during one update so two responses cannot compete for the single pending interaction. The selected button reports which item is changing. Links and navigation remain available.

Add the two props to WorkItemListProps in src/components/WorkItemList.tsx:

src/components/WorkItemList.tsx — pending props
type WorkItemListProps = {
items: readonly WorkItem[];
emptyMessage: string;
updatingItemId: string | null;
statusUpdatesDisabled: boolean;
onStatusChange: WorkItemStatusChangeHandler;
};

Receive them and pass them to each card:

src/components/WorkItemList.tsx — pass selected pending state
<WorkItemCard
item={item}
isUpdating={updatingItemId === item.id}
statusUpdatesDisabled={statusUpdatesDisabled}
onStatusChange={onStatusChange}
/>

The stable item ID connects one network operation to one card without copying the full item into separate pending state.

Await success or failure in the board page

Section titled “Await success or failure in the board page”

Add this state beside the existing status message in src/pages/BoardPage.tsx:

src/pages/BoardPage.tsx — one pending update
const [updatingItemId, setUpdatingItemId] = useState<string | null>(null);

Replace handleBoardStatusChange:

src/pages/BoardPage.tsx — server-owned status result
async function handleBoardStatusChange(
itemId: string,
nextStatus: WorkStatus,
): Promise<void> {
if (updatingItemId !== null) {
return;
}
const changedItem = items.find((item) => item.id === itemId);
if (changedItem === undefined) {
setStatusMessage('The selected work item is no longer available.');
statusMessageRef.current?.focus();
return;
}
setUpdatingItemId(itemId);
setStatusMessage(`${changedItem.title} is updating.`);
try {
await onStatusChange(itemId, nextStatus);
setStatusMessage(
`${changedItem.title} changed to ${statusLabels[nextStatus]}.`,
);
if (filter !== 'all' && filter !== nextStatus) {
statusMessageRef.current?.focus();
}
} catch {
setStatusMessage(
`${changedItem.title} could not be updated. Try the action again.`,
);
statusMessageRef.current?.focus();
} finally {
setUpdatingItemId(null);
}
}

Pass the pending state to WorkItemList:

src/pages/BoardPage.tsx — list connection
<WorkItemList
items={visibleItems}
emptyMessage={emptyMessage}
updatingItemId={updatingItemId}
statusUpdatesDisabled={updatingItemId !== null}
onStatusChange={handleBoardStatusChange}
/>

The failure branch does not modify the collection. App changes state only after the adapter returns a validated server item. Focus moves to the stable status message so keyboard and screen-reader users receive the failure and next action.

The success branch keeps the earlier rule:

  • if the card remains visible, focus stays on its re-enabled button;
  • if the current filter removes the card, focus moves to the live status message.

Add a visible disabled treatment without reducing contrast:

src/styles.scss — pending action state
.work-item-action:disabled {
cursor: wait;
opacity: 0.72;
}

Do not rely on opacity alone. The disabled state and changing accessible name provide programmatic and visible information.

Remove the client fixture after success works

Section titled “Remove the client fixture after success works”

Run Express and Vite. Confirm that the loaded cards match server/data/initial-work-items.ts, then search for the old import:

Confirm that no runtime source uses the client fixture
rg "sampleWorkItems|sample-work-items" src

Update any remaining tests in the next section. When no application or test source imports the file, remove it:

Remove the migrated client data source
git rm src/data/sample-work-items.ts

Do not delete src/types/work-item.ts. Both client and server still use the shared static model declaration. The server-owned initial data remains in server/data/initial-work-items.ts.

Checkpoint: Status changes cross the API without losing accepted state

What now works
The loaded server collection drives every route; one pending operation disables status actions and names the selected item; success replaces one record from validated JSON; failure keeps the prior record and focuses retryable feedback; the client fixture is no longer a runtime source.
Files changed
src/App.tsx, src/pages/BoardPage.tsx, src/components/WorkItemList.tsx, src/components/WorkItemCard.tsx, src/styles.scss
What remains
Adapt the client tests to an injected API, add response-state coverage, and verify both local process pairs in real browsers.
Next action
Create one reusable fake WorkItemsApi in App.test.tsx and make renderApp await the ready heading.
If it does not work
Trace button pending state, API request, response status, JSON parse, item validation, App replacement, Board feedback, and focus in that order.

Test client behavior without a live server

Section titled “Test client behavior without a live server”

Client tests must not depend on an Express process, port timing, or the current server fixture. App accepts a WorkItemsApi, so tests can supply controlled promises at the correct boundary.

Add a reusable fixture and fake near the top of src/App.test.tsx:

src/App.test.tsx — deterministic API boundary
import {
ApiClientError,
type WorkItemsApi,
} from './api/work-items-api';
import type { WorkItem } from './types/work-item';
const testItems: WorkItem[] = [
{
id: 'WI-101',
title: 'Verify the client API boundary',
description: 'Render only data accepted by the client contract.',
status: 'planned',
labels: ['client', 'quality'],
},
{
id: 'WI-102',
title: 'Document retry behavior',
description: 'Keep the recovery action visible after a failed load.',
status: 'active',
labels: ['client', 'documentation'],
},
];
function copyItems(items: readonly WorkItem[]): WorkItem[] {
return items.map((item) => ({
...item,
labels: [...item.labels],
}));
}
function createFakeApi(
overrides: Partial<WorkItemsApi> = {},
): WorkItemsApi {
return {
list: vi.fn(async () => copyItems(testItems)),
updateStatus: vi.fn(async (itemId, status) => {
const item = testItems.find((candidate) => candidate.id === itemId);
if (item === undefined) {
throw new Error('Missing test item');
}
return { ...item, status, labels: [...item.labels] };
}),
...overrides,
};
}

Update the existing renderApp helper to accept an API and wait for ready state:

src/App.test.tsx — render the ready application
async function renderApp(
initialEntry = '/',
api = createFakeApi(),
) {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={[initialEntry]}>
<App api={api} />
</MemoryRouter>,
);
await screen.findByRole('heading', { name: 'Board overview' });
return { api, user };
}

Every existing routed-client test must await renderApp(...). Replace old fixture-specific expectations with the local testItems values. Preserve the eight Stage 2 behavior contracts; the fake changes only the external data owner.

Update the focused empty-list component test with the new props:

src/components/WorkItemList.test.tsx — added props
<WorkItemList
items={[]}
emptyMessage="No work items are available."
updatingItemId={null}
statusUpdatesDisabled={false}
onStatusChange={vi.fn(async () => {})}
/>

Add a small deferred helper and test:

src/App.test.tsx — loading before the API resolves
it('shows loading until the first collection request resolves', async () => {
let resolveList: ((items: WorkItem[]) => void) | undefined;
const listPromise = new Promise<WorkItem[]>((resolve) => {
resolveList = resolve;
});
const api = createFakeApi({
list: vi.fn(() => listPromise),
});
render(
<MemoryRouter>
<App api={api} />
</MemoryRouter>,
);
expect(
screen.getByRole('heading', { name: 'Loading Delivery Board' }),
).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Loading work items');
resolveList?.(copyItems(testItems));
expect(
await screen.findByRole('heading', { name: 'Board overview' }),
).toBeInTheDocument();
});

The optional function call is safe because the promise constructor runs synchronously and assigns resolveList before the test reaches it.

src/App.test.tsx — initial failure can retry
it('retries a failed initial collection request', async () => {
const user = userEvent.setup();
const list = vi
.fn<WorkItemsApi['list']>()
.mockRejectedValueOnce(
new ApiClientError('network', 'The API could not be reached.'),
)
.mockResolvedValueOnce(copyItems(testItems));
const api = createFakeApi({ list });
render(
<MemoryRouter>
<App api={api} />
</MemoryRouter>,
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'could not load work items',
);
await user.click(screen.getByRole('button', { name: 'Try again' }));
expect(
await screen.findByRole('heading', { name: 'Board overview' }),
).toBeInTheDocument();
expect(list).toHaveBeenCalledTimes(2);
});

Prove a failed update keeps accepted state

Section titled “Prove a failed update keeps accepted state”
src/App.test.tsx — rejected update preserves the item
it('keeps the accepted item and focuses feedback after update failure', async () => {
const api = createFakeApi({
updateStatus: vi.fn(async () => {
throw new ApiClientError('http', 'The API returned HTTP 500.');
}),
});
const { user } = await renderApp('/', api);
await user.click(
screen.getByRole('button', {
name: 'Start work: Verify the client API boundary',
}),
);
const feedback = await screen.findByRole('status');
expect(feedback).toHaveTextContent('could not be updated');
expect(feedback).toHaveFocus();
expect(screen.getByText('Planned')).toBeInTheDocument();
});

Use within if Planned appears in more than one part of your page. The important assertion is that the selected card retains its last accepted status.

Test invalid successful JSON at the adapter boundary

Section titled “Test invalid successful JSON at the adapter boundary”

Create src/api/work-items-api.test.ts:

src/api/work-items-api.test.ts
import { describe, expect, it, vi } from 'vitest';
import { createWorkItemsApi } from './work-items-api';
describe('workItemsApi', () => {
it('rejects a successful response with duplicate work-item IDs', async () => {
const fetcher = vi.fn(async () =>
new Response(
JSON.stringify({
data: [
{
id: 'WI-101',
title: 'First item',
description: 'First valid description.',
status: 'planned',
labels: ['quality'],
},
{
id: 'WI-101',
title: 'Duplicate identity',
description: 'Second valid description.',
status: 'active',
labels: ['api'],
},
],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
const api = createWorkItemsApi(fetcher);
await expect(api.list()).rejects.toMatchObject({
kind: 'contract',
});
});
});

This test proves that 200 OK does not bypass the client data contract.

Run the focused files, then the complete portfolio:

Run client API-state tests
npm test -- src/api/work-items-api.test.ts src/App.test.tsx
npm test
npm run typecheck
npm run lint
npm run build

The complete count depends on earlier Stage 2 extensions. No existing client or server contract may be skipped to make the connection tests pass.

Checkpoint: Client tests control the API boundary and asynchronous states

What now works
Existing route tests await deterministic fake data; loading, failure and retry, invalid successful JSON, and rejected updates have automated evidence; no client behavior test requires a live Express process.
Files changed
src/App.test.tsx, src/api/work-items-api.test.ts, src/components/WorkItemList.test.tsx, Complete Vitest output
What remains
Run development and production-preview process pairs, inspect network and accessibility behavior, document limits, and commit the connection.
Next action
Start Express and Vite in separate terminals, then verify direct and filtered client routes from a cold page load.
If it does not work
Separate fake-API behavior, adapter parsing, live proxy behavior, server response, and rendered client state before changing a test expectation.

Verify the connected application in real processes

Section titled “Verify the connected application in real processes”

Automated client tests use a fake API. API tests use the Express application directly. The connected browser path needs both normal processes.

Start from a clean prompt with no earlier server process:

Terminal 1 — Express development process
npm run dev:server
Terminal 2 — Vite development process
npm run dev

Open the Vite URL and inspect the Network panel. The first collection request must use the browser-visible Vite origin and relative path /api/work-items. Its response must be JSON from Express through the proxy.

Verify this matrix:

Path or action Required result
Cold load at / Loading appears, then the complete server collection
Cold load at /?status=planned Loading appears, then the Planned filter and matching server items
Cold load at /work-items/WI-001 Loading appears, then the server-backed detail result
Cold load at /work-items/WI-999 Loading appears, then the existing Work item not found recovery
Valid status action Selected action reports updating, then the server status and feedback agree
Filter removes updated card Focus moves to the stable status feedback
Navigate to detail after update Detail route shows the accepted server status
Browser reload after update Server retains the update while the Express process remains active
Express restart and client reload The item returns to the documented initial server value

Use the real accepted item IDs. The request to WI-999 is valid only when that ID is absent.

Stop only Terminal 1 with Ctrl+C. Reload the client.

Confirm:

  • the page shows Delivery Board unavailable;
  • the alert gives a concise load failure;
  • no empty collection is claimed;
  • Try again is a native button; and
  • the Console contains no unhandled promise rejection.

Start npm run dev:server again. Activate Try again without reloading the page. Loading must appear, followed by the server collection.

With a loaded board, stop Express. Activate one status button.

Confirm:

  • the pending label appears until the request rejects;
  • the prior visible status remains;
  • focused feedback says that the item could not be updated;
  • status buttons become available again; and
  • restarting Express and repeating the action succeeds.

Do not use the browser Console to assign React state or edit the rendered status text. The test must exercise the actual request path.

Keyboard, narrow, zoom, and Console checks

Section titled “Keyboard, narrow, zoom, and Console checks”

Repeat the connected path with:

  • Tab and Shift+Tab through primary navigation, filter, item action, detail link, and retry when visible;
  • Enter on links and buttons, plus the native select keyboard keys;
  • a 320 CSS-pixel viewport;
  • 200% browser zoom at a normal desktop width;
  • no clipped loading, error, card, status, or action text;
  • no horizontal page scrolling;
  • visible focus in loading recovery, ready navigation, and update failure; and
  • no unexpected Console error or warning.

Do not communicate loading, failure, or pending state through color alone.

Verify the built client with the local API

Section titled “Verify the built client with the local API”

Stop Vite development in Terminal 2. Keep or restart Express in Terminal 1. Run:

Build and preview the connected client
npm run build
npm run preview

Open the reported preview URL. Vite preview inherits the /api proxy from server.proxy. Repeat at least:

  • cold collection load;
  • one valid filtered URL;
  • one known detail URL;
  • one unknown item URL;
  • one successful status update;
  • initial failure and retry after stopping and restarting Express; and
  • keyboard, narrow viewport, zoom, overflow, and Console checks.

Stop only the preview process and Express process that you started.

The preview proves that the built client assets work with the local verification proxy. It does not prove a public API deployment, production reverse proxy, HTTPS, process supervisor, database, or cross-origin policy.

Update README with:

  1. the Express-first and Vite-second process order;
  2. development and preview commands plus exact stop actions;
  3. the relative /api rule and local proxy target;
  4. why no unrestricted CORS policy is required;
  5. client response validation rules and error categories;
  6. loading, populated, empty, failure, retry, pending, update success, and update failure behavior;
  7. abort and obsolete-result cleanup;
  8. pessimistic update ownership;
  9. client fixture removal and server fixture ownership;
  10. fake API injection and added test scenarios;
  11. complete current client and API test results;
  12. development and preview browser evidence;
  13. restart reset and memory-only state lifetime;
  14. PHP transfer: the client contract depends on HTTP and JSON rather than the server language; and
  15. current limits: local proxies, no public server, database, auth, tokens, offline cache, conflict policy, deployment, or production CORS decision.

Run the final gate with no local process active:

Verify the complete connected repository
npm test
npm run typecheck
npm run lint
npm run build
git status --short
git diff --check
git diff

Search for obsolete fixture ownership and development shortcuts:

Audit the final source boundary
rg "sampleWorkItems|sample-work-items|localhost:3000|Access-Control-Allow-Origin" src server vite.config.ts

Expected result:

  • no client fixture import or source remains;
  • client source contains only relative /api paths;
  • only Vite configuration contains the local proxy target;
  • no unrestricted CORS header exists; and
  • the server still owns initialWorkItems.

Create the focused connection commit:

Commit the connected client
git add README.md vite.config.ts src/App.tsx src/App.test.tsx
git add src/api/work-items-api.ts src/api/work-items-api.test.ts
git add src/pages/BoardPage.tsx src/components/WorkItemList.tsx
git add src/components/WorkItemList.test.tsx src/components/WorkItemCard.tsx
git add src/styles.scss src/data/sample-work-items.ts
git diff --cached --check
git diff --cached
git commit -m "feat: connect React client to work-item API"
git status

Staging the removed fixture path records its deletion. The final status must be clean on feature/express-api. Keep the branch local for Project stage 3 review.

Self-check

Complete these checks against the required result.

  1. Confirm that the lesson started from the clean validated-API commit and that client and server passed independently.
  2. Point to the six defined client states and explain why empty success is not loading or failure.
  3. Confirm that client requests use relative /api paths and only Vite configuration names 127.0.0.1:3000.
  4. Explain how the proxy avoids an unnecessary unrestricted CORS policy in local development.
  5. Trace a successful collection from Response through JSON unknown, envelope checks, item checks, duplicate checks, copied values, and ready state.
  6. Confirm that IDs match the course format, text is nonempty, status belongs to the union, labels are strings, and labels and IDs are unique.
  7. Explain why response.ok and a shared TypeScript declaration do not validate response data.
  8. Distinguish network, HTTP, JSON, and contract failures in the adapter.
  9. Confirm that raw response bodies and exception objects do not render in the page.
  10. Inspect the collection request method, Accept header, relative URL, and abort signal.
  11. Inspect the status request encoded ID, PATCH method, JSON headers, serialized body, and response ID and status match.
  12. Explain why App uses one loading, ready, or error union instead of competing booleans.
  13. Confirm that Effect cleanup both aborts the request and ignores an obsolete late result.
  14. Explain why React Strict Mode can run a development setup and cleanup cycle without requiring removal of Strict Mode.
  15. Confirm that retry increments the attempt and starts another loading request without a page reload.
  16. Trace one status action through pending identity, disabled buttons, API update, validated representation, state replacement, feedback, and focus.
  17. Confirm that a failed update keeps the last accepted status and moves focus to stable retryable feedback.
  18. Confirm that filtered-card removal still moves focus after a successful update.
  19. Confirm that no client application or test imports the deleted sample-work-items file.
  20. Explain why the server fixture remains and why process restart resets its in-memory store.
  21. Confirm that existing React tests inject an API, await ready state, and retain all eight Stage 2 contracts.
  22. Run the added loading, retry, invalid-response, and failed-update tests.
  23. Run every client and API test in one deterministic command.
  24. Verify development cold routes, status update, initial retry, update failure, restart behavior, keyboard, focus, narrow viewport, zoom, overflow, and Console.
  25. Repeat the bounded connected matrix against built client assets and the local preview proxy.
  26. Run complete typecheck, lint, and build after all tests.
  27. Confirm that README records processes, proxy, validation, states, evidence, PHP transfer, limits, and recovery.
  28. Inspect the staged diff, create the focused connection commit, and confirm a clean feature branch.

Explanation: an API response is input, not state

Section titled “Explanation: an API response is input, not state”

The server and client share vocabulary, but HTTP is still a runtime boundary. The adapter converts transport evidence into application values. React receives a valid collection, a valid updated item, or a bounded failure.

Keeping fetch and parsing outside components has three benefits:

  • components render domain values instead of interpreting transport details;
  • tests can supply a fake API without opening a server; and
  • every network path uses the same validation and error model.

The client load union makes uncertainty explicit. No collection exists during initial loading. A valid empty array is accepted evidence that the collection has no records. A failure means no trustworthy response was available. These states require different language and actions.

The update path uses the server as the current authority. The client does not guess that a requested value was accepted. It waits, validates the returned representation, checks that identity and status match the request, and then replaces one record.

The validated adapter, explicit states, server-backed updates, tests, two-process browser evidence, documentation, and focused commit complete this lesson.

The required result is safely paused when client and server tests pass, no deliberate invalid response remains, development and preview process pairs pass the connected matrix, update failure preserves accepted state, no process remains active, typecheck, lint, and build pass, the connection commit exists, and git status is clean on feature/express-api.

Continue to Project stage 3: Add and connect an Express API. That assignment will ask for independent contract variation, review evidence, fresh-clone recovery, and integration of the complete server-and-client stage into main.