Skip to main content

Chapter 8: Forms & Validation

TaskFlow's simple TaskForm from Chapter 2 works, but it won't scale. What happens when you need 10 fields? Validation? Error messages? Loading states? This chapter upgrades your form game with React Hook Form, Zod schemas, and React 19's new form primitives that eliminate boilerplate you didn't know you were writing.

๐Ÿ“Œ Where we are: TaskFlow has routing (Ch 7), a task detail page, and the Settings page. The TaskForm is still using basic controlled inputs with useState. Time to level up.

๐Ÿ“Œ React 19 features introduced: useActionState, useFormStatus, useOptimistic, and native <form action>. These are game-changers.


๐Ÿง  Conceptsโ€‹

1. Why Your Current Form Won't Scaleโ€‹

In Chapter 2, you built TaskForm with controlled inputs โ€” useState for each field, onChange handlers, manual validation. That's fine for one or two fields, but:

// Chapter 2 style โ€” gets painful fast
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState("medium");
const [dueDate, setDueDate] = useState("");
const [category, setCategory] = useState("");
const [assignee, setAssignee] = useState("");
// ... 6 useState calls, 6 onChange handlers, re-renders on every keystroke

The scaling problems:

  • Re-render cost: Every keystroke re-renders the entire form (all 6 fields)
  • Validation spaghetti: Where do you put "title must be 3+ chars"? When do you show the error?
  • Submit state: Is it submitting? Did it fail? How do you disable the button?
  • Type safety: How do you ensure the submitted data matches what the API expects?

React Hook Form + Zod solve all of these. Let's see how.

2. React Hook Form โ€” The Sweet Spotโ€‹

React Hook Form (RHF) takes the uncontrolled approach internally (fewer re-renders) but gives you a controlled-feeling API. It:

  • Uses refs under the hood โ†’ minimal re-renders
  • Provides a register() function to connect inputs
  • Handles validation, dirty checking, error state
  • Integrates with any validation library via resolvers
register("fieldName") โ†’ returns { name, ref, onChange, onBlur }
โ†“
Input stays uncontrolled (DOM-owned)
โ†“
On submit, RHF reads all values at once
โ†“
Validates โ†’ either errors or clean data

3. Zod โ€” Schema-First Validationโ€‹

Zod lets you define a schema (shape of valid data) and validate against it. The schema serves double duty โ€” it validates at runtime AND generates TypeScript types.

import { z } from "zod";

const taskSchema = z.object({
title: z.string().min(1, "Title is required").max(100, "Too long"),
description: z.string().optional(),
priority: z.enum(["low", "medium", "high"]),
dueDate: z.string().optional(),
});

// Extract the TypeScript type from the schema!
type TaskFormData = z.infer<typeof taskSchema>;
// โ†’ { title: string; description?: string; priority: "low" | "medium" | "high"; dueDate?: string }

Why Zod over hand-rolled validation?

  • Single source of truth (schema โ†’ types + validation)
  • Composable (.extend(), .pick(), .merge())
  • Great error messages out of the box
  • Works on client AND server

4. ๐Ÿ†• React 19: Native Form Actionsโ€‹

React 19 lets you pass an async function directly to <form action>:

<form action={async (formData: FormData) => {
const title = formData.get("title") as string;
await saveTask({ title });
}}>
<input name="title" />
<button type="submit">Save</button>
</form>

Here's what happens under the hood:

No e.preventDefault() needed. No manual state. React handles it all.

This is not the same as HTML native form submission. React intercepts the submit, calls your function client-side, and manages the lifecycle.

5. ๐Ÿ†• React 19: useActionStateโ€‹

Remember the old pattern of manually tracking isPending, error, and result for form submissions? useActionState replaces all of it:

// BEFORE (React 18) โ€” manual boilerplate
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);

async function handleSubmit(data: TaskFormData) {
setIsPending(true);
setError(null);
try {
await saveTask(data);
} catch (e) {
setError(e.message);
} finally {
setIsPending(false);
}
}

// AFTER (React 19) โ€” useActionState handles it
const [error, submitAction, isPending] = useActionState(
async (previousState: string | null, formData: FormData) => {
try {
await saveTask(Object.fromEntries(formData));
return null; // no error
} catch (e) {
return (e as Error).message; // return error as new state
}
},
null // initial state (no error)
);

<form action={submitAction}>
{error && <p className="error">{error}</p>}
<button disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
</form>

The signature: useActionState(actionFn, initialState) returns [state, wrappedAction, isPending].

6. ๐Ÿ†• React 19: useFormStatusโ€‹

Need a submit button component in your design system that automatically knows if its parent form is submitting? useFormStatus reads the form's pending state without prop drilling:

import { useFormStatus } from "react-dom";

const SubmitButton = ({ label = "Save" }: { label?: string }) => {
const { pending } = useFormStatus();

return (
<button type="submit" disabled={pending}>
{pending ? "Saving..." : label}
</button>
);
}

Important: useFormStatus reads the status of the parent <form>. The component using it must be a child of a <form> with an action prop.

7. ๐Ÿ†• React 19: useOptimisticโ€‹

Show the result before the server confirms it. If the action fails, React automatically reverts to the real value:

const [optimisticTasks, addOptimisticTask] = useOptimistic(
tasks, // actual current value
(currentTasks, newTask: Task) => [...currentTasks, newTask]
);

During the async action, call addOptimisticTask(newTask) โ€” the UI immediately shows the new task. When the action completes (or fails), optimisticTasks snaps back to the real tasks value.


๐Ÿ’ก Examplesโ€‹

React Hook Form + Zod โ€” Full Patternโ€‹

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const taskSchema = z.object({
title: z.string().min(1, "Title is required").max(100),
description: z.string().max(500).optional(),
priority: z.enum(["low", "medium", "high"]),
});

type TaskFormData = z.infer<typeof taskSchema>;

export default function TaskForm({
onSubmit,
defaultValues,
}: {
onSubmit: (data: TaskFormData) => void;
defaultValues?: Partial<TaskFormData>;
}) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
defaultValues: {
title: "",
description: "",
priority: "medium",
...defaultValues,
},
});

async function onValid(data: TaskFormData) {
await onSubmit(data);
reset();
}

return (
<form onSubmit={handleSubmit(onValid)} noValidate>
<div className="form-field">
<label htmlFor="title">Title</label>
<input id="title" {...register("title")} />
{errors.title && (
<span className="field-error">{errors.title.message}</span>
)}
</div>

<div className="form-field">
<label htmlFor="description">Description</label>
<textarea id="description" {...register("description")} />
{errors.description && (
<span className="field-error">{errors.description.message}</span>
)}
</div>

<div className="form-field">
<label htmlFor="priority">Priority</label>
<select id="priority" {...register("priority")}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
{errors.priority && (
<span className="field-error">{errors.priority.message}</span>
)}
</div>

<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Task"}
</button>
</form>
);
}

Watch โ€” Live Field Valuesโ€‹

const { register, watch } = useForm<TaskFormData>();

// Watch a specific field
const priority = watch("priority");

// Use it for conditional rendering
{priority === "high" && (
<p className="warning">โš ๏ธ High priority tasks appear at the top!</p>
)}

๐Ÿ†• React 19 Form Action + useActionState โ€” Complete Exampleโ€‹

import { useActionState } from "react";
import { useFormStatus } from "react-dom";

// The action function
async function createTaskAction(
prevState: { error: string | null },
formData: FormData
): Promise<{ error: string | null }> {
const title = formData.get("title") as string;

if (!title || title.trim().length === 0) {
return { error: "Title is required" };
}

try {
await fetch("/api/tasks", {
method: "POST",
body: JSON.stringify({ title }),
headers: { "Content-Type": "application/json" },
});
return { error: null };
} catch {
return { error: "Failed to create task. Try again." };
}
}

const SubmitButton = () => {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Task"}
</button>
);
}

const QuickAddForm = () => {
const [state, formAction, isPending] = useActionState(createTaskAction, {
error: null,
});

return (
<form action={formAction}>
<input
name="title"
placeholder="What needs to be done?"
required
/>
<SubmitButton />
{state.error && <p className="error">{state.error}</p>}
</form>
);
}

๐Ÿ†• Optimistic Updates with useOptimisticโ€‹

import { useOptimistic } from "react";

interface Task {
id: string;
title: string;
status: "todo" | "in-progress" | "done";
}

const TaskList = ({ tasks, toggleTask }: {
tasks: Task[];
toggleTask: (id: string) => Promise<void>;
}) {
const [optimisticTasks, updateOptimisticTask] = useOptimistic(
tasks,
(currentTasks, updatedTask: Task) =>
currentTasks.map((t) => (t.id === updatedTask.id ? updatedTask : t))
);

async function handleToggle(task: Task) {
const nextStatus = task.status === "done" ? "todo" : "done";
const optimistic = { ...task, status: nextStatus as Task["status"] };

// Show the change immediately
updateOptimisticTask(optimistic);

// Actually perform the update (reverts on failure)
await toggleTask(task.id);
}

return (
<ul>
{optimisticTasks.map((task) => (
<li key={task.id} onClick={() => handleToggle(task)}>
<span>{task.status === "done" ? "โœ…" : "โฌœ"}</span>
<span>{task.title}</span>
</li>
))}
</ul>
);
}

๐Ÿ”จ Project Task: Upgrade TaskFlow Formsโ€‹

Step 1: Install Dependenciesโ€‹

npm install react-hook-form @hookform/resolvers zod

Step 2: Define the Task Schemaโ€‹

Create src/schemas/task.ts:

import { z } from "zod";

export const taskSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(100, "Title must be under 100 characters"),
description: z
.string()
.max(500, "Description must be under 500 characters")
.optional()
.or(z.literal("")),
priority: z.enum(["low", "medium", "high"], {
errorMap: () => ({ message: "Please select a priority" }),
}),
status: z.enum(["todo", "in-progress", "done"]).default("todo"),
dueDate: z.string().optional().or(z.literal("")),
});

export type TaskFormData = z.infer<typeof taskSchema>;

// For editing โ€” all fields optional except we keep the shape
export const editTaskSchema = taskSchema.partial().required({ title: true });
export type EditTaskFormData = z.infer<typeof editTaskSchema>;

Step 3: Build the New TaskForm Componentโ€‹

Replace your existing task form with src/components/TaskForm.tsx:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { taskSchema, type TaskFormData } from "../schemas/task";

interface TaskFormProps {
onSubmit: (data: TaskFormData) => Promise<void> | void;
defaultValues?: Partial<TaskFormData>;
submitLabel?: string;
}

export default function TaskForm({
onSubmit,
defaultValues,
submitLabel = "Create Task",
}: TaskFormProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
reset,
watch,
} = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
defaultValues: {
title: "",
description: "",
priority: "medium",
status: "todo",
dueDate: "",
...defaultValues,
},
});

const priority = watch("priority");

async function onValid(data: TaskFormData) {
await onSubmit(data);
if (!defaultValues) {
reset(); // Only reset on create, not edit
}
}

return (
<form onSubmit={handleSubmit(onValid)} noValidate className="task-form">
{/* Title */}
<div className="form-field">
<label htmlFor="title">
Title <span className="required">*</span>
</label>
<input
id="title"
type="text"
placeholder="What needs to be done?"
{...register("title")}
aria-invalid={errors.title ? "true" : "false"}
/>
{errors.title && (
<p className="field-error" role="alert">
{errors.title.message}
</p>
)}
</div>

{/* Description */}
<div className="form-field">
<label htmlFor="description">Description</label>
<textarea
id="description"
rows={3}
placeholder="Add details..."
{...register("description")}
/>
{errors.description && (
<p className="field-error" role="alert">
{errors.description.message}
</p>
)}
</div>

{/* Priority */}
<div className="form-field">
<label htmlFor="priority">Priority</label>
<select id="priority" {...register("priority")}>
<option value="low">๐ŸŸข Low</option>
<option value="medium">๐ŸŸก Medium</option>
<option value="high">๐Ÿ”ด High</option>
</select>
{priority === "high" && (
<p className="field-hint">
โš ๏ธ High priority tasks will appear at the top of the list.
</p>
)}
</div>

{/* Status (for editing) */}
{defaultValues && (
<div className="form-field">
<label htmlFor="status">Status</label>
<select id="status" {...register("status")}>
<option value="todo">To Do</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
)}

{/* Due Date */}
<div className="form-field">
<label htmlFor="dueDate">Due Date</label>
<input id="dueDate" type="date" {...register("dueDate")} />
</div>

{/* Submit */}
<button type="submit" disabled={isSubmitting || !isDirty}>
{isSubmitting ? "Saving..." : submitLabel}
</button>
</form>
);
}

Step 4: Create the Edit Task Pageโ€‹

src/pages/EditTask.tsx

import { useParams, useNavigate } from "react-router-dom";
import { useContext } from "react";
import { TaskContext } from "../context/TaskContext";
import TaskForm from "../components/TaskForm";
import type { TaskFormData } from "../schemas/task";

const EditTask = () => {
const { id } = useParams<{ id: string }>();
const { tasks, updateTask } = useContext(TaskContext);
const navigate = useNavigate();

const task = tasks.find((t) => t.id === id);

if (!task) {
return (
<div>
<h2>Task not found</h2>
<button onClick={() => navigate("/")}>โ† Back</button>
</div>
);
}

async function handleUpdate(data: TaskFormData) {
updateTask(task!.id, data);
navigate(`/task/${task!.id}`);
}

return (
<div className="edit-task-page">
<h1>Edit Task</h1>
<TaskForm
onSubmit={handleUpdate}
defaultValues={{
title: task.title,
description: task.description ?? "",
priority: task.priority,
status: task.status,
}}
submitLabel="Update Task"
/>
</div>
);
}

Step 5: Add the Edit Routeโ€‹

Update src/App.tsx:

import EditTask from "./pages/EditTask";

// Inside your Routes:
<Route path="task/:id/edit" element={<EditTask />} />

Step 6: Add a Quick-Add Form with React 19 Actionsโ€‹

Create a quick-add bar at the top of the Dashboard using useActionState:

src/components/QuickAdd.tsx

import { useActionState, useContext } from "react";
import { useFormStatus } from "react-dom";
import { TaskContext } from "../context/TaskContext";

const AddButton = () => {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Adding..." : "+ Add"}
</button>
);
}

const QuickAdd = () => {
const { addTask } = useContext(TaskContext);

const [error, formAction] = useActionState(
async (_prev: string | null, formData: FormData) => {
const title = formData.get("title") as string;

if (!title.trim()) {
return "Title cannot be empty";
}

// Simulate network delay
await new Promise((r) => setTimeout(r, 300));

addTask({
title: title.trim(),
priority: "medium",
status: "todo",
});

return null; // success โ€” form resets automatically
},
null
);

return (
<form action={formAction} className="quick-add">
<input
name="title"
placeholder="Quick add a task..."
autoComplete="off"
/>
<AddButton />
{error && <p className="field-error">{error}</p>}
</form>
);
}

Step 7: Verifyโ€‹

Test your forms thoroughly:

  • Submit an empty title โ†’ "Title is required" error appears
  • Type a title over 100 characters โ†’ validation error
  • Create a task โ†’ form resets, task appears in list
  • Navigate to edit page โ†’ form pre-fills with existing data
  • Edit and save โ†’ changes persist, redirects to detail page
  • Quick-add form โ†’ button shows "Adding..." during submit
  • Quick-add with empty input โ†’ inline error message

๐Ÿงช Challengeโ€‹

  1. Field-level async validation: Add an async check to the title field โ€” simulate checking if a task with that title already exists. Use RHF's validate option:

    register("title", {
    validate: async (value) => {
    const exists = await checkTaskExists(value);
    return exists ? "A task with this title already exists" : true;
    },
    });
  2. Optimistic task creation: Use useOptimistic to show the task in the list immediately when using QuickAdd, before the "server" confirms.

  3. Multi-step form: Split task creation into two steps: (1) title + priority, (2) description + due date. Use RHF's trigger() to validate step 1 before proceeding.


๐Ÿ“š Further Readingโ€‹


Next up: Chapter 9 โ€” Tailwind CSS Fundamentals โ†’

Time to make TaskFlow look as good as it works. We'll replace all CSS with Tailwind's utility-first approach.