Course navigationPhase 21
Volume 06 · Phase 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.

16lessons + labs
5klesson words
9source references
What you will ship

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.

Figure 21.1 — Where this phase lives in TaskFlow
input controls
React Hook Form state
Zod resolver
submit mutation
API validation/business rules
field/non-field feedback
Trace the flow left-to-right for implementation and right-to-left for debugging. At every arrow, ask what data crosses the boundary and what proves it is trustworthy.
Instructor mental model

Principles to keep while coding

01

React Hook Form should own transient form interaction while Zod defines the input contract.

02

Validate close to the user for feedback, but repeat trust-boundary validation on the backend.

03

Map server validation errors back to fields without losing non-field errors.

04

Reset or preserve form state deliberately after successful mutations based on the workflow.

Code-along path

Guided phase lab

Do these in order. Each step should end with evidence before the next one starts.

  1. 01

    Define form values and defaults before wiring UI fields.

  2. 02

    Register basic fields and observe dirty/touched/submitting state intentionally.

  3. 03

    Connect Zod resolver and reuse only rules that genuinely belong in both client and API validation.

  4. 04

    Implement accessible field errors and focus behavior after invalid submit.

  5. 05

    Send data through the mutation layer and map server validation/conflict errors back to fields or form-level feedback.

  6. 06

    Implement edit/reset/cancel semantics and test what happens when server data changes while a form is open.

Debugging studio

Failures you should know how to diagnose

Failure drill 1

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.

Failure drill 2

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.

Failure drill 3

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.

Failure drill 4

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.

Watch-order style

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.

01

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

2 min
02

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

2 min
03

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

2 min
04

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

2 min
05

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

2 min
06

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

2 min
07

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

2 min
08

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

2 min
09

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

2 min
10

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

2 min
11

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

2 min
12

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

2 min
LAB

Guided Build Lab

Purpose Build robust forms with React Hook Form and Zod while separating transient form state from authoritative server state.

3 min
LAB

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.

2 min
LAB

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?

2 min
LAB

Mastery Capstone Lab

Integrate Phase 21 without following a transcript.

2 min
Practice ladder

Exercises that increase independence

Guided

Define form values and defaults before wiring UI fields.

Practice

Connect Zod resolver and reuse only rules that genuinely belong in both client and API validation.

Debug

reproduce and diagnose — Duplicating server data into form state without reset strategy.

Independent

deliver Create/edit task forms with shared field rules, accessible errors, async submission, server-error mapping, and reset behavior.

Explain-back

Can distinguish form state from server state and Can build schema-backed forms.

Mastery gate

Do not leave the phase only because you reached the bottom

Phase exit criteria

Knowledge check

Scenario and concept quiz

These questions test engineering choices rather than whether you remember a lesson title.

0/0 correct · 0/8 answered
01Which practice best matches Phase 21's engineering goal?
02Which practice best matches Phase 21's engineering goal?
03Which practice best matches Phase 21's engineering goal?
04Which practice best matches Phase 21's engineering goal?
05You are working through the Phase 21 lab. Which next action is the most disciplined step?
06You are working through the Phase 21 lab. Which next action is the most disciplined step?
07You are working through the Phase 21 lab. Which next action is the most disciplined step?
08You are working through the Phase 21 lab. Which next action is the most disciplined step?
Source snapshot

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.