Form Libraries
Build complex forms with React Hook Form and Zod validation
Forms are everywhere. React Hook Form (RHF) + Zod cuts boilerplate by 60% vs manual state.
What you'll learn
Understand why form libraries improve DX and perf
Set up RHF with register, handleSubmit, and formState
Define Zod schemas for type-safe validation
Integrate RHF with Zod via @hookform/resolvers
Handle complex forms: arrays, nested objects, conditional fields
Build a multi-field registration form with validation
Why Form Libraries?
| Problem | Manual Approach | RHF Approach |
|---|---|---|
| Controlled re-renders | Every keystroke re-renders whole form | Uncontrolled inputs, isolated renders |
| Validation | Write from scratch | Zod/Yup schemas |
| Error display | Custom state per field | formState.errors auto-wired |
| Field arrays | Manual add/remove logic | useFieldArray |
Key Insight
RHF uses uncontrolled inputs by default. Means zero re-renders on keystroke. Only re-renders the field that changes.
Basic Setup
import { useForm } from "react-hook-form"
type FormData = {
name: string
email: string
}
function SimpleForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>()
const onSubmit = (data: FormData) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: "Name is required" })} />
{errors.name && <p>{errors.name.message}</p>}
<input
{...register("email", {
required: true,
pattern: { value: /^\S+@\S+$/i, message: "Invalid email" },
})}
/>
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Submit</button>
</form>
)
}
Zod Schema Validation
Defining validation separately keeps types and rules in sync:
import { z } from "zod"
const loginSchema = z.object({
email: z.string().email("Must be a valid email"),
password: z.string().min(8, "Min 8 characters"),
})
type LoginData = z.infer<typeof loginSchema>
// { email: string; password: string }
RHF + Zod Integration
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const schema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email address"),
age: z.coerce.number().min(18, "Must be 18 or older"),
})
type Schema = z.infer<typeof schema>
function ValidatedForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Schema>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("username")} />
{errors.username && <p role="alert">{errors.username.message}</p>}
<input {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="number" {...register("age")} />
{errors.age && <p role="alert">{errors.age.message}</p>}
<button type="submit">Register</button>
</form>
)
}
Gotcha: Number Inputs
HTML inputs return strings. Use z.coerce.number() to parse correctly.
Without coerce, a number field validates as string and fails.
Controlled vs Uncontrolled
Field Arrays
import { useFieldArray, useForm } from "react-hook-form"
type InvoiceForm = {
items: { name: string; quantity: number; price: number }[]
}
function Invoice() {
const { control, register } = useForm<InvoiceForm>({
defaultValues: { items: [{ name: "", quantity: 1, price: 0 }] },
})
const { fields, append, remove } = useFieldArray({ control, name: "items" })
return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} placeholder="Item name" />
<input type="number" {...register(`items.${index}.quantity`)} />
<input type="number" {...register(`items.${index}.price`)} />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: "", quantity: 1, price: 0 })}
>
Add Item
</button>
</>
)
}
Challenge
Build a registration form with email, password, password confirmation, and a terms checkbox. Form must validate passwords match.
Registration Form
Code
tsx
1import { useForm } from "react-hook-form"2import { zodResolver } from "@hookform/resolvers/zod"3import { z } from "zod"4 5// Step 1: Define schema with password confirmation6// Hint: use .refine() or .superRefine() to check passwords match7// Step 2: Create type from schema8// Step 3: Set up useForm with zodResolver9// Step 4: Render fields with register + error messages10 11type FormValues = {12email: string13password: string14confirmPassword: string15acceptTerms: boolean16}17 18export default function RegisterForm() {19// Your code here20return (21 <form>22 <div>23 <label>Email</label>24 <input type="email" />25 </div>26 <div>27 <label>Password</label>28 <input type="password" />29 </div>30 <div>31 <label>Confirm Password</label>32 <input type="password" />33 </div>34 <div>35 <label>36 <input type="checkbox" /> I accept the terms37 </label>38 </div>39 <button type="submit">Register</button>40 </form>41)42}Key Takeaways
RHF uses uncontrolled inputs via refs — zero re-renders on keystroke
Zod schemas define both validation rules and TypeScript types
@hookform/resolvers/zod bridges RHF and Zod
useFieldArray handles dynamic lists of form fields
z.coerce.number() parses string inputs before validation
z.literal(true) enforces boolean checkbox acceptance