14. Forms & Validation
14. Forms & Validation
Forms are a cornerstone of web applications, yet they are often a source of bugs, duplicated validation logic, and poor type safety. This chapter walks through a modern stack that eliminates those pain points:
- React Hook Form (RHF) – minimal re‑renders, easy registration, and built‑in error handling.
- Zod – declarative schemas that validate data at runtime and infer exact TypeScript types.
- Next.js Server Actions – server‑side form processing without writing an API route, enabling direct database calls.
By the end of this chapter you will be able to build a form that:
- Registers each input with
registerand attaches validation rules. - Uses a Zod schema as a resolver so validation errors are automatically synced with RHF.
- Submits via a Server Action, validates the payload with Zod, persists data with an ORM, and returns typed success/error responses.
- Displays field‑level errors and provides a smooth user experience.
React Hook Form Basics
Setting Up the Form
Start by initializing RHF with useForm. The returned object gives you register, handleSubmit, and formState (which contains errors and isSubmitting).
import { useForm } from "react-hook-form";
export default function Signup() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => console.log(data);
return (
);
}
Registering Inputs
Each input receives the spreaded result of register(name, options). The name must match the field you want in the form state.
<input {...register("email")} />
<input {...register("password")} />
Built‑In Validation Rules
Pass an object as the second argument to register. RHF supports the following validators out of the box:
required– boolean or string message.min/max– numeric value.minLength/maxLength– string length.pattern– RegExp with optional custom message.validate– custom function returning boolean or string message.
<input
{...register("email", {
required: "Email is required",
pattern: {
value: /^\S+@\S+$/i,
message: "Invalid email format",
},
})}>
<input
{...register("password", {
required: "Password is required",
minLength: { value: 8, message: "At least 8 characters" },
})}>
Errors are automatically placed in formState.errors. Render them conditionally:
{errors.email && <span className="error">{errors.email.message}</span>}
{errors.password && <span className="error">{errors.password.message}</span>}
Custom Validator Functions
When built‑in rules aren’t enough, use the validate property. It can return true, false, or a string message.
<input
{...register("username", {
validate: (value) =>
/^[a-zA-Z0-9_]+$/.test(value) ||
"Only letters, numbers, and underscores allowed",
})}>
Handling Submission
handleSubmit wraps your submit handler and only calls it when the form passes validation. It receives the validated data.
const onSubmit = (data) => {
// data is typed according to the resolver (see Zod section)
console.log("Submitted:", data);
};
If you need to perform asynchronous work (e.g., an API call), simply make the handler async and await inside.
const onSubmit = async (data) => {
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
};
Resetting the formState also exposes reset to clear fields or set default values.
const { reset } = useForm();
// Call reset() to clear, or reset({ email: "user@example.com" }) to pre‑fill.
Integrating Zod for Schema‑First Validation
Why Zod?
const { reset } = useForm();
// Call reset() to clear, or reset({ email: "user@example.com" }) to pre‑fill.
React Hook Form validates well, but keeping validation rules in two places (JSX and TypeScript) leads to drift. Zod lets you define a single source of truth:
- Runtime validation (
schema.parse/schema.safeParse). - Compile‑time TypeScript inference via
z.infer<typeof schema>.
Defining a Zod Schema
import { z } from "zod";
const signupSchema = z.object({
email: z.string().email({ message: "Invalid email" }),
password: z.string()
.min(8, { message: "Password must be at least 8 characters" })
.regex(/[A-Z]/, { message: "Password needs an uppercase letter" }),
// optional: confirm password via refinement
confirmPassword: z.string(),
}).refine((data) => data.password