React Hook Form Patterns
Purpose
Provide patterns for building performant forms with React Hook Form and Zod validation, including basic forms, nested objects, dynamic field arrays, controlled components with Controller, file uploads, async validation, and UI library integration.
Basic Form with Zod
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Minimum 8 characters"),
rememberMe: z.boolean().optional(),
});
type LoginFormData = z.infer<typeof loginSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "", rememberMe: false },
});
const (data: LoginFormData) => {
await loginUser(data);
};
return (
<form
<input {...register("email")} aria-invalid={!!errors.email} />
{errors.email && <span role="alert">{errors.email.message}</span>}
<input type="password" {...register("password")} />
{errors.password && <span role="alert">{errors.password.message}</span>}
<label>
<input type="checkbox" {...register("rememberMe")} />
Remember me
</label>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Logging in..." : "Log in"}
</button>
</form>
);
}
Nested Object Schemas
const userSchema = z.object({
firstName: z.string().min(1, "Required"),
lastName: z.string().min(1, "Required"),
address: z.object({
street: z.string().min(1, "Required"),
city: z.string().min(1, "Required"),
zipCode: z.string().regex(/^\d{5}$/, "Must be 5 digits"),
}),
});
type UserFormData = z.infer<typeof userSchema>;
function UserForm() {
const { register, formState: { errors } } = useForm<UserFormData>({
resolver: zodResolver(userSchema),
});
return (
<form>
<input {...register("firstName")} />
<input {...register("address.street")} />
<input {...register("address.city")} />
<input {...register("address.zipCode")} />
{errors.address?.zipCode && <span>{errors.address.zipCode.message}</span>}
</form>
);
}
Dynamic Field Arrays
import { useForm, useFieldArray } from "react-hook-form";
const orderSchema = z.object({
items: z
.array(
z.object({
productId: z.string().min(1, "Required"),
quantity: z.number().min(1, "Min 1"),
notes: z.string().optional(),
})
)
.min(1, "At least one item required"),
});
type OrderFormData = z.infer<typeof orderSchema>;
function OrderForm() {
const { control, register, handleSubmit, formState: { errors } } = useForm<OrderFormData>({
resolver: zodResolver(orderSchema),
defaultValues: { items: [{ productId: "", quantity: 1, notes: "" }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.productId`)} />
<input
type="number"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
<button type="button" => remove(index)}>Remove</button>
{errors.items?.[index]?.productId && (
<span>{errors.items[index]?.productId?.message}</span>
)}
</div>
))}
<button type="button" => append({ productId: "", quantity: 1, notes: "" })}>
Add Item
</button>
<button type="submit">Submit</button>
</form>
);
}
Controlled Components with Controller
import { Controller, useForm } from "react-hook-form";
const eventSchema = z.object({
title: z.string().min(1),
date: z.date(),
category: z.enum(["meeting", "deadline", "reminder"]),
});
function EventForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(eventSchema),
});
return (
<form
<Controller
name="date"
control={control}
render={({ field, fieldState: { error } }) => (
<DatePicker
selected={field.value}
error={error?.message}
/>
)}
/>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select
value={field.value}
options={[
{ label: "Meeting", value: "meeting" },
{ label: "Deadline", value: "deadline" },
]}
/>
)}
/>
</form>
);
}
UI Library Integration (Shadcn)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
function ProfileForm() {
const form = useForm({
resolver: zodResolver(profileSchema),
defaultValues: { username: "", bio: "" },
});
return (
<Form {...form}>
<form
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
}
File Upload Validation
const uploadSchema = z.object({
title: z.string().min(1),
file: z
.instanceof(FileList)
.refine((files) => files.length > 0, "File is required")
.refine((files) => files[0]?.size <= 5 * 1024 * 1024, "Max 5MB")
.refine(
(files) => ["image/jpeg", "image/png"].includes(files[0]?.type),
"Only JPEG/PNG allowed"
),
});
function UploadForm() {
const { register, handleSubmit } = useForm({
resolver: zodResolver(uploadSchema),
});
const (data: z.infer<typeof uploadSchema>) => {
const formData = new FormData();
formData.append("file", data.file[0]);
await uploadFile(formData);
};
return (
<form
<input {...register("title")} />
<input type="file" {...register("file")} accept="image/jpeg,image/png" />
<button type="submit">Upload</button>
</form>
);
}
Async Validation
function RegisterForm() {
const form = useForm({
resolver: zodResolver(registerSchema),
mode: "onBlur",
});
const validateUsername = async (username: string) => {
const res = await fetch(`/api/check-username?username=${username}`);
const { available } = await res.json();
if (!available) {
form.setError("username", { type: "manual", message: "Username taken" });
}
};
return (
<form>
<input
{...form.register("username")}
=> validateUsername(e.target.value)}
/>
</form>
);
}
Best Practices
- Define Zod schemas in separate files for reuse and unit testing
- Use
z.infer<typeof schema>for type inference instead of manual types - Set
mode: "onBlur"for better UX (validate when user leaves a field) - Use
Controllerfor third-party components that don't supportref - Use
useFieldArrayfor dynamic arrays; never manually manage array indexes - Always provide
defaultValuesto avoid uncontrolled-to-controlled warnings - Show errors with
role="alert"for accessibility - Use
isSubmittingto disable the submit button during async operations - Use
valueAsNumberfor number inputs to avoid string coercion - Compose schemas with
z.object().merge()or.extend()for multi-step forms