Learn / TypeScript and React Product Systems
Server State, Forms, and Tests
Build a production form flow with typed API parsing, TanStack Query ownership, optimistic cache updates, rollback, accessible errors, and behavior-focused tests.
Course: TypeScript and React Product Systems. Level: Intermediate. Topic: Frontend systems.
Stage: advanced - Advanced product data flows - Server state, forms, and optimistic recovery. Make server-owned data explicit, keep form drafts local, and prove optimistic paths roll back correctly.
Outcomes
- Separate local form draft state from server-owned query data and mutation state.
- Parse API responses at the boundary before updating the cache or rendering UI.
- Implement optimistic project creation with a snapshot rollback and invalidation path.
- Test the form through roles, labels, pending state, error announcement, and success behavior.
- Type async functions, promise results, and concurrent operations.
- Reason about event loop work, microtasks, and blocking code.
- Use AbortSignal and timeout patterns for cancellable operations.
- Use Node.js and Fetch types without hiding unknown runtime data.
- Design typed API clients around status codes, parsing, and validation.
- Separate transport concerns from domain-level service functions.
- Separate domain logic from adapters and framework code.
- Design interfaces around use cases rather than infrastructure details.
- Model errors and results for predictable callers.
Concepts
- query key ownership
- typed API boundary
- form validation
- optimistic rollback
- aria-live error
- Testing Library behavior test
- Async TypeScript and the Event Loop
- Async functions return promises
- Cancellation and timeouts
- TypeScript Advanced foundations
- Guided practice
- Node and Fetch APIs
- Fetch returns transport facts
- Node APIs and resource ownership
- Architecture Patterns
- Ports and adapters
- Error design
Concept flow
Show how local drafts, validation, mutation state, optimistic cache changes, rollback, invalidation, and behavior tests fit together.
- Form draft
- Client validation
- Mutation
- Optimistic cache
- Rollback snapshot
- Invalidate query
- Accessible test
Session flow
- Own the server boundary (concept, 12 min) — Keep query keys, fetchers, parsing, and invalidation in one feature-owned boundary.
- Query keys are product contracts, not incidental strings.
- Unknown JSON should be parsed before it enters the cache.
- Separate drafts from server state (walkthrough, 12 min) — Use local state for unsaved field drafts while server lists stay in TanStack Query.
- A form draft can be invalid without polluting server cache state.
- Pending mutation state should drive duplicate-submit prevention.
- Make optimism reversible (demo, 16 min) — Snapshot cache data, apply the optimistic row, restore it on error, and invalidate after settle.
- Rollback starts before the optimistic write, not after the error.
- Invalidation reconciles local confidence with server truth.
- Test user-visible behavior (exercise, 16 min) — Assert labels, roles, alert text, pending button copy, and cache rollback through the user path.
- Role and label queries keep tests aligned with accessible UI.
- A useful failed-mutation test proves both user feedback and data recovery.
- Typed Async Flow: Async functions return promises (concept, 35 min) — An async function always returns a Promise. Type the resolved value, not the internal await steps. For parallel independent operations, start both promises before awaiting their combined result.
- Retained source example: Parallel typed awaits
async function buildDashboard(userId: string): Promise<{ user: User; alerts: Alert[] }> {
const userPromise = getUser(userId);
const alertsPromise = listAlerts(userId);
const [user, alerts] = await Promise.all([userPromise, alertsPromise]);
return { user, alerts };
}
- Typed Async Flow: Cancellation and timeouts (walkthrough, 35 min) — JavaScript promises do not cancel themselves. APIs such as fetch accept AbortSignal, which gives the caller a way to stop waiting and release work when a request is no longer useful.
- Retained source example: Timeout with AbortController
async function getJson(url: string, timeoutMs: number): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} finally {
clearTimeout(timer);
}
}
- Typed Async Flow: practices and mistakes (review, 5 min) — Retained guidance from the legacy PTesting lesson.
- Practice: Do independent I/O concurrently, but limit fan-out when calling external systems.
- Practice: Always clean up timers used for timeouts.
- Practice: Model expected failures as data when callers can recover.
- Avoid: Awaiting independent work sequentially by habit.
- Avoid: Blocking the event loop with CPU-heavy loops in request handlers.
- Avoid: Assuming Promise.race cancels losing promises.
- Typed Async Flow: references (review, 2 min) — Original references retained from the legacy library.
- MDN: async function: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
- Node.js event loop guide: https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
- Typed Platform APIs: Fetch returns transport facts (concept, 33 min) — A Response tells you status, headers, and body access methods. It does not tell TypeScript the domain shape of JSON. A client wrapper should check status, parse the body as unknown, validate it, then return a domain type.
- Retained source example: Typed client shape
type ApiError = { status: number; message: string };
async function requestJson(url: string): Promise<unknown> {
const response = await fetch(url, { headers: { accept: 'application/json' } });
if (!response.ok) {
throw { status: response.status, message: response.statusText } satisfies ApiError;
}
return response.json();
}
- Typed Platform APIs: Node APIs and resource ownership (walkthrough, 33 min) — Node.js APIs often involve streams, files, sockets, environment variables, and process state. Types help document contracts, but your code must still close resources, handle errors, and keep side effects at the edge.
- Retained source example: Typed environment reader
function requiredEnv(name: string, source: NodeJS.ProcessEnv = process.env): string {
const value = source[name];
if (!value) throw new Error(`Missing environment variable ${name}`);
return value;
}
- Typed Platform APIs: practices and mistakes (review, 5 min) — Retained guidance from the legacy PTesting lesson.
- Practice: Return domain types only after parsing and validation.
- Practice: Keep API client methods small and explicit about failure modes.
- Practice: Centralize environment parsing at startup.
- Avoid: Typing response.json as the desired interface without validation.
- Avoid: Sprinkling process.env reads throughout business logic.
- Avoid: Throwing unstructured values that callers cannot handle predictably.
- Typed Platform APIs: references (review, 2 min) — Original references retained from the legacy library.
- MDN: Fetch API: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
- Node.js API documentation: https://nodejs.org/api/
- Typed Service Architecture: Ports and adapters (concept, 38 min) — A useful backend architecture puts domain services in the center and framework, database, queue, and HTTP clients at the edge. TypeScript interfaces can describe the port the domain needs without importing a specific adapter.
- Retained source example: Domain service with ports
interface UserStore {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface IdGenerator {
next(prefix: string): string;
}
function createUserService(store: UserStore, ids: IdGenerator) {
return {
async register(input: CreateUserInput): Promise<User> {
const existing = await store.findByEmail(input.email);
if (existing) throw new Error('Email already registered');
const user = { id: ids.next('usr'), ...input };
await store.save(user);
return user;
},
};
}
- Typed Service Architecture: Error design (walkthrough, 38 min) — Throwing Error is fine for unexpected failures, but expected domain outcomes are often clearer as typed results. The choice should be consistent so route handlers and jobs can map failures predictably.
- Retained source example: Typed domain error
type RegisterError =
| { code: 'email_taken'; email: string }
| { code: 'invalid_plan'; planId: string };
type RegisterResult = Result<User, RegisterError>;
- Typed Service Architecture: practices and mistakes (review, 5 min) — Retained guidance from the legacy PTesting lesson.
- Practice: Keep framework request and response types out of core domain services.
- Practice: Prefer small interfaces named after the caller's need.
- Practice: Make expected errors explicit and exhaustively mapped at boundaries.
- Avoid: Letting ORM models leak into every layer.
- Avoid: Using one giant service type for unrelated capabilities.
- Avoid: Mixing transport status codes into domain rules.
- Typed Service Architecture: references (review, 2 min) — Original references retained from the legacy library.
- Martin Fowler: Inversion of Control Containers and Dependency Injection: https://martinfowler.com/articles/injection.html
- TypeScript handbook: Interfaces: https://www.typescriptlang.org/docs/handbook/2/objects.html
Code example
TSX in features/projects/CreateProjectForm.tsx.
import { FormEvent, useId, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
type Project = { id: string; name: string; owner: string };
type CreateProjectInput = { name: string; owner: string };
const projectsKey = ["projects"] as const;
function parseProject(value: unknown): Project {
if (
typeof value === "object" &&
value !== null &&
typeof (value as Project).id === "string" &&
typeof (value as Project).name === "string" &&
typeof (value as Project).owner === "string"
) {
return value as Project;
}
throw new Error("Project response was not valid.");
}
async function createProject(input: CreateProjectInput): Promise<Project> {
const response = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error("Project creation failed. Try again.");
return parseProject(await response.json());
}
export function CreateProjectForm() {
const queryClient = useQueryClient();
const errorId = useId();
const [draft, setDraft] = useState<CreateProjectInput>({ name: "", owner: "" });
const [fieldError, setFieldError] = useState("");
const mutation = useMutation({
mutationFn: createProject,
onMutate: async (input) => {
await queryClient.cancelQueries({ queryKey: projectsKey });
const previous = queryClient.getQueryData<Project[]>(projectsKey) ?? [];
queryClient.setQueryData<Project[]>(projectsKey, [
{ id: "optimistic", name: input.name, owner: input.owner },
...previous,
]);
return { previous };
},
onError: (_error, _input, context) => {
queryClient.setQueryData(projectsKey, context?.previous ?? []);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: projectsKey }),
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const next = { name: draft.name.trim(), owner: draft.owner.trim() };
if (next.name.length < 3) {
setFieldError("Project name must be at least 3 characters.");
return;
}
setFieldError("");
mutation.mutate(next, {
onSuccess: () => setDraft({ name: "", owner: "" }),
});
}
const error = fieldError || (mutation.isError ? "Project creation failed. Try again." : "");
return (
<form onSubmit={submit} aria-describedby={error ? errorId : undefined}>
<label>
Project name
<input
value={draft.name}
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
aria-invalid={Boolean(fieldError)}
/>
</label>
<label>
Owner
<input
value={draft.owner}
onChange={(event) => setDraft((current) => ({ ...current, owner: event.target.value }))}
/>
</label>
{error && <p id={errorId} role="alert">{error}</p>}
<button disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create project"}
</button>
</form>
);
}
Walkthrough examples
- Project creation feature packet — A team adds a project creation drawer to a dashboard. The product owner wants instant feedback, but failed requests must not leave ghost rows.
- File: features/projects/api.ts
- File: features/projects/queryKeys.ts
- File: features/projects/CreateProjectForm.tsx
- File: features/projects/CreateProjectForm.test.tsx
- File: features/projects/testServer.ts
- Create one stable projects query key and reuse it in read and mutation code.
- Parse the create response at the fetcher boundary before returning Project.
- Validate fields locally and announce validation errors through role="alert".
- Use onMutate to cancel, snapshot, and optimistically add the pending project.
- Use onError to restore the snapshot and onSettled to invalidate projects.
- Test validation, pending state, success reset, and failed rollback with role and label queries.
- Parallel typed awaits — Additional example retained from the legacy lesson.
- Retained source code:
async function buildDashboard(userId: string): Promise<{ user: User; alerts: Alert[] }> {
const userPromise = getUser(userId);
const alertsPromise = listAlerts(userId);
const [user, alerts] = await Promise.all([userPromise, alertsPromise]);
return { user, alerts };
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Timeout with AbortController — Additional example retained from the legacy lesson.
- Retained source code:
async function getJson(url: string, timeoutMs: number): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return await response.json();
} finally {
clearTimeout(timer);
}
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Result for expected failures — Additional example retained from the legacy lesson.
- Retained source code:
type FetchResult<T> =
| { ok: true; data: T }
| { ok: false; status: number; message: string };
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Typed client shape — Additional example retained from the legacy lesson.
- Retained source code:
type ApiError = { status: number; message: string };
async function requestJson(url: string): Promise<unknown> {
const response = await fetch(url, { headers: { accept: 'application/json' } });
if (!response.ok) {
throw { status: response.status, message: response.statusText } satisfies ApiError;
}
return response.json();
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Typed environment reader — Additional example retained from the legacy lesson.
- Retained source code:
function requiredEnv(name: string, source: NodeJS.ProcessEnv = process.env): string {
const value = source[name];
if (!value) throw new Error(`Missing environment variable ${name}`);
return value;
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- API client interface — Domain code can depend on this interface while a Fetch or database adapter handles transport details.
- Retained source code:
interface UserDirectory {
findByEmail(email: string): Promise<User | null>;
create(input: CreateUserInput): Promise<User>;
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Domain service with ports — Additional example retained from the legacy lesson.
- Retained source code:
interface UserStore {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface IdGenerator {
next(prefix: string): string;
}
function createUserService(store: UserStore, ids: IdGenerator) {
return {
async register(input: CreateUserInput): Promise<User> {
const existing = await store.findByEmail(input.email);
if (existing) throw new Error('Email already registered');
const user = { id: ids.next('usr'), ...input };
await store.save(user);
return user;
},
};
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Typed domain error — Additional example retained from the legacy lesson.
- Retained source code:
type RegisterError =
| { code: 'email_taken'; email: string }
| { code: 'invalid_plan'; planId: string };
type RegisterResult = Result<User, RegisterError>;
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Route maps domain result to HTTP — Additional example retained from the legacy lesson.
- Retained source code:
function toStatus(error: RegisterError): number {
switch (error.code) {
case 'email_taken': return 409;
case 'invalid_plan': return 400;
}
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
Practice
- Create a stable projects query key and name which component owns invalidation.
- Parse the project response before putting data into the query cache.
- Keep field drafts in component state and server lists in TanStack Query.
- Add an optimistic project row, snapshot previous cache data, and restore it on failure.
- Expose validation and server failures through labelled controls and an alert/live region.
- Write tests for validation, pending state, success reset, and failed rollback behavior.
- Classify async work: Identify whether database reads that do not depend on each other should usually be awaited sequentially or with Promise.all. The guided runner checks for the concurrency primitive name.
- Accepted answers: Promise.all
- Apply: Type async functions, promise results, and concurrent operations.
- Spot the trust boundary: What type should raw response.json data usually have before validation? The guided runner checks the type keyword.
- Accepted answers: unknown
- Apply: Use Node.js and Fetch types without hiding unknown runtime data.
- Identify a port: In the service example, name one interface that acts as a domain port. The guided runner checks interface names only.
- Accepted answers: UserStore | IdGenerator
- Apply: Separate domain logic from adapters and framework code.
Checklist
- Query keys are stable and owned by the feature, not copied as string literals across the app.
- External JSON is parsed before cache writes or rendering.
- Local form draft state is separate from server-owned query state.
- Optimistic mutation captures a rollback snapshot before changing cache data.
- Submit controls prevent duplicate requests while pending.
- Validation and server errors are visible and announced through accessible UI.
- Tests use role and label queries instead of implementation-only selectors.
Quiz prompts
- Where should unknown project API JSON become trusted typed data? — Parsing once at the boundary keeps the rest of the UI working with known shapes and safer failure modes.
- What must happen before an optimistic cache update? — The snapshot is the rollback source if the mutation fails, and cancellation avoids stale responses racing the optimistic value.
- Which state belongs in local React state instead of the server cache? — Draft input is local and temporary. Server-owned data should live behind the query cache and mutation lifecycle.
- What makes a form failure test product-relevant? — Behavior tests should prove what users and assistive technology can observe, not only implementation internals.
- After a mutation settles, why invalidate the projects query? — Invalidation lets the server-confirmed project list replace the optimistic snapshot once the mutation lifecycle finishes.
- What does AbortSignal provide to fetch? — AbortSignal coordinates cancellation with APIs that support it.
- What does response.ok represent? — response.ok is a transport status convenience, not domain validation.
- Why define a UserStore interface in the domain layer? — The interface is a port that adapters can implement.
Flashcards
- What does a query key own? The identity of one server-owned data set plus the invalidation contract for mutations that change it.
- Why parse API responses before cache writes? The cache should hold trusted application data, not unknown JSON that can break render paths later.
- Where should an unsaved form draft live? In local component or form state, because it is temporary UI state rather than server-owned data.
- What is the rollback snapshot? The previous query data captured before an optimistic update, restored if the mutation fails.
- How should a form expose an async error accessibly? Render visible error text tied to the form and announce it with an alert or live region.
- Why prefer role and label queries in form tests? They verify the same accessible surface users and assistive technologies rely on.
Labs
- Build a rollback-safe project creation form — Implement a project creation flow with typed API parsing, local validation, optimistic cache insert, rollback on failure, invalidation, accessible errors, and behavior tests.
- Define projectsKey and use it for both project reads and create invalidation.
- Write createProject with a typed input and a response parser that rejects malformed JSON.
- Keep the form draft local and validate name and owner before mutation.
- Add an optimistic project row with a rollback snapshot and restore on mutation failure.
- Expose validation and server errors through labelled controls and role="alert".
- Write tests for validation, pending button state, success reset, and rollback after a failed request.
- A parser test proves malformed API responses never enter the query cache.
- A failed-mutation test proves the exact previous project list is restored.
- A pending-state test proves the submit button prevents duplicate creates.
- An accessibility review note proves every input has a label and visible error path.
- Testing Library output shows user-event plus role/label queries for the primary behavior paths.
Challenge
- Review-ready server-state form (Stretch) — Ship a create/edit form whose server-state ownership, optimistic rollback, validation, accessible errors, and behavior tests are clear enough for a teammate to review without hidden context.
- Query keys, API parser, and mutation invalidation are feature-owned and documented in code.
- Local draft state and server cache state never overwrite each other accidentally.
- Optimistic update includes cancellation, snapshot, rollback, and invalidate-on-settle behavior.
- Validation and server errors are announced and tested through accessible queries.
- Tests prove success, failure, pending, and rollback paths from the user perspective.
Canonical lesson URL