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.
Concepts
- query key ownership
- typed API boundary
- form validation
- optimistic rollback
- aria-live error
- Testing Library behavior test
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.
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.
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.
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.
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