React Hook Form should own transient form interaction while Zod defines the input contract.
Course navigationPhase 21⌄
Forms with React Hook Form + Zod
Build robust forms with React Hook Form and Zod while separating transient form state from authoritative server state.
Phase outcome and project increment
Learning outcome
Build robust forms with React Hook Form and Zod while separating transient form state from authoritative server state.
Concrete deliverable
Create/edit task forms with shared field rules, accessible errors, async submission, server-error mapping, and reset behavior.
Principles to keep while coding
Validate close to the user for feedback, but repeat trust-boundary validation on the backend.
Map server validation errors back to fields without losing non-field errors.
Reset or preserve form state deliberately after successful mutations based on the workflow.
Guided phase lab
Do these in order. Each step should end with evidence before the next one starts.
- 01
Define form values and defaults before wiring UI fields.
- 02
Register basic fields and observe dirty/touched/submitting state intentionally.
- 03
Connect Zod resolver and reuse only rules that genuinely belong in both client and API validation.
- 04
Implement accessible field errors and focus behavior after invalid submit.
- 05
Send data through the mutation layer and map server validation/conflict errors back to fields or form-level feedback.
- 06
Implement edit/reset/cancel semantics and test what happens when server data changes while a form is open.
Failures you should know how to diagnose
Duplicating server data into form state without reset strategy
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Assuming client validation secures the API
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Clearing user input on every failed request
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Showing only generic toast instead of actionable field errors
Predict the symptom, capture evidence at the nearest boundary, change one variable, and verify the same reproduction after the fix.
Lessons
The source chapters come first. Every phase now ends with a guided build, a debugging workshop, and an independent challenge so reading turns into transferable skill.
Form State and Submission Mental Model
Form State & Submission Mental Model Understand what a form owns before installing a form library Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend A form is a te
React Hook Form Architecture and Version Lock
React Hook Form Architecture Install the library and understand its local form-state model Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend Install from the web
Zod Resolver and Shared Validation Rules
Zod Resolver & Shared Validation Rules Reuse rules without confusing form, transport and persistence contracts Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend W
Accessible Fields Errors and Validation UX
Accessible Fields, Errors & Validation UX Make forms understandable to keyboard, screen-reader and sighted users Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend
Create Task Form End to End
Create Task Form - End to End Build the first real browser form against the secured NestJS API Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend Build CreateTaskF
Edit Forms Reset Dirty and Default Values
Edit Forms, Reset, Dirty State & Defaults Model the difference between loaded data, unsaved edits and saved server state Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional
Nullable Optional Date Select and Coercion Rules
Nullable, Optional, Dates, Selects & Coercion Translate browser-native values into deliberate API semantics Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend Opti
Submission Pending Server Errors and Duplicate Submits
Submission, Pending State & Server Errors Handle asynchronous failures without losing user input Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend Client validati
Controller Custom Components and Field Composition
Controller & Custom Components Know when native registration ends and an adapter is justified Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend Prefer register fo
Shared Validator Package and Form Boundaries
Shared Validator Package & Boundaries Extract only validation concepts with a real second consumer Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend Now a shared
Form Testing Debugging and Common Failures
Form Testing, Debugging & Common Failures Verify user-visible behavior and diagnose the correct layer Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend Test behav
Hands On Lab and Phase Exit
Hands-On Lab & Phase Exit Move from tutorial form code to independent form architecture decisions Phase 21 - Forms with React Hook Form + Zod Volume 06 - Professional Frontend Hands-on lab I
Guided Build Lab
Purpose Build robust forms with React Hook Form and Zod while separating transient form state from authoritative server state.
Debugging Workshop
Goal Learn to diagnose this phase's failures systematically instead of reinstalling packages, changing random configuration, or copying a fix without understanding it.
Phase Synthesis & Independent Challenge
Mission Rebuild the capability of Phase 21 without following the exact code line-by-line. The goal is transfer: can you use the ideas when the variable names, UI, or route shape changes?
Mastery Capstone Lab
Integrate Phase 21 without following a transcript.
Exercises that increase independence
Define form values and defaults before wiring UI fields.
Connect Zod resolver and reuse only rules that genuinely belong in both client and API validation.
reproduce and diagnose — Duplicating server data into form state without reset strategy.
deliver Create/edit task forms with shared field rules, accessible errors, async submission, server-error mapping, and reset behavior.
Can distinguish form state from server state and Can build schema-backed forms.
Do not leave the phase only because you reached the bottom
Phase exit criteria
Scenario and concept quiz
These questions test engineering choices rather than whether you remember a lesson title.
Reference files
Attempt the work first. Use snapshots to compare a checkpoint, not as a substitute for understanding.
reference/TaskFlow_Form_Snapshot/README.md
# TaskFlow Phase 21 reference snapshot This snapshot demonstrates the form architecture taught in Phase 21. It is a teaching reference, not a drop-in replacement for earlier phase source files. Integrate the changes deliberately. The key design is: React Hook Form owns transient form state; Zod validates the client experience; NestJS validates the HTTP request again; business rules remain in services; PostgreSQL remains authoritative persistence.
reference/TaskFlow_Form_Snapshot/apps/api/src/tasks/tasks.schemas.phase21.ts
import { z } from "zod";
import { taskDescriptionSchema, taskPrioritySchema, taskTitleSchema } from "@taskflow/validators/tasks";
export const createTaskSchema = z.strictObject({
projectId: z.uuid(),
title: taskTitleSchema,
description: taskDescriptionSchema.nullable().optional(),
priority: taskPrioritySchema.default("MEDIUM"),
dueAt: z.iso.datetime().nullable().optional(),
});
export const updateTaskSchema = createTaskSchema.omit({ projectId: true }).partial().refine(
(value) => Object.keys(value).length > 0,
{ message: "At least one field must be provided." },
);reference/TaskFlow_Form_Snapshot/apps/web/src/components/tasks/task-form.module.css
.form { display: grid; gap: 1rem; max-width: 48rem; }
.field { display: grid; gap: .375rem; }
.field label { font-weight: 650; }
.field input, .field textarea, .field select { width: 100%; padding: .75rem; border: 1px solid var(--border); border-radius: .625rem; background: var(--surface); color: var(--text); }
.field [aria-invalid="true"] { border-color: #b42318; }
.field [role="alert"], .formError { margin: 0; color: #b42318; font-size: .875rem; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.actions { display: flex; justify-content: flex-end; }
.actions button { min-height: 2.75rem; padding: .625rem 1rem; }
.actions button:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 640px) { .row { grid-template-columns: 1fr; } .actions button { width: 100%; } }reference/TaskFlow_Form_Snapshot/apps/web/src/components/tasks/task-form.schema.ts
import { z } from "zod";
import { taskDescriptionSchema, taskPrioritySchema, taskTitleSchema } from "@taskflow/validators/tasks";
export const taskFormSchema = z.object({
title: taskTitleSchema,
description: taskDescriptionSchema,
priority: taskPrioritySchema,
dueAtLocal: z.string(),
});
export type TaskFormValues = z.infer<typeof taskFormSchema>;reference/TaskFlow_Form_Snapshot/apps/web/src/components/tasks/task-form.tsx
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { createTask, updateTask } from "@/lib/api/task-mutations";
import { taskFormSchema, type TaskFormValues } from "./task-form.schema";
import styles from "./task-form.module.css";
type Props = {
mode: "create" | "edit";
projectId: string;
taskId?: string;
defaultValues: TaskFormValues;
};
export function TaskForm({ mode, projectId, taskId, defaultValues }: Props) {
const router = useRouter();
const { register, handleSubmit, reset, setError, clearErrors, formState: { errors, isDirty, isSubmitting } } = useForm<TaskFormValues>({
resolver: zodResolver(taskFormSchema),reference/TaskFlow_Form_Snapshot/apps/web/src/lib/api/task-mutations.ts
import { createClient } from "@/lib/supabase/client";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL!;
async function authHeaders() {
const supabase = createClient();
const { data, error } = await supabase.auth.getSession();
if (error || !data.session?.access_token) throw new Error("Authentication required.");
return {
"Content-Type": "application/json",
Authorization: `Bearer ${data.session.access_token}`,
};
}
export async function createTask(input: unknown) {
const response = await fetch(`${API_BASE_URL}/tasks`, {
method: "POST",
headers: await authHeaders(),
body: JSON.stringify(input),
});reference/TaskFlow_Form_Snapshot/packages/validators/package.json
{
"name": "@taskflow/validators",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./tasks": "./src/tasks.ts"
},
"dependencies": {
"zod": "4.6.5"
}
}reference/TaskFlow_Form_Snapshot/packages/validators/src/tasks.ts
import { z } from "zod";
export const taskTitleSchema = z.string().trim().min(1, "Title is required.").max(200);
export const taskDescriptionSchema = z.string().trim().max(2000);
export const taskPrioritySchema = z.enum(["LOW", "MEDIUM", "HIGH"]);
export const taskStatusSchema = z.enum(["TODO", "IN_PROGRESS", "DONE", "CANCELED"]);reference/TaskFlow_Form_Snapshot/tests/form-test-matrix.md
# Phase 21 form test matrix - Blank title -> inline error; request not sent. - Unknown browser-only field -> not included in transport mapping. - Create success -> navigate/refresh Task list. - Edit success -> reset accepted values and clear dirty state. - API 401 -> preserve values and show authentication/recovery message. - API 403 -> preserve values and show permission message. - API 409 -> preserve values and show conflict message. - API 500 -> generic safe message; include requestId when client error helper exposes it. - Double-click submit -> submit control disabled while isSubmitting is true. - Keyboard-only -> labels, focus, errors and submit remain operable. - Mobile width -> fields stack; button remains reachable without horizontal page overflow.