React Forms & Validation: React Hook Form + Zod in Production
Build bulletproof forms with React Hook Form and Zod — schema validation, nested fields, file uploads, multi-step forms, async validation, and accessibility best practices.
Why React Hook Form + Zod
The problem with native form handling:
// ❌ The naive approach — 50+ lines for a simple form
function NaiveForm() {
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState("");
const [password, setPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [loading, setLoading] = useState(false);
const validate = () => {
let valid = true;
if (!email.includes("@")) { setEmailError("Invalid email"); valid = false; }
if (password.length < 8) { setPasswordError("Too short"); valid = false; }
return valid;
};
// This approach: re-renders on every keystroke, validation logic scattered,
// no schema sharing, no type safety, no accessibility
}React Hook Form + Zod:
- Uncontrolled inputs — minimal re-renders (only on error state change)
- Zod schemas are the single source of truth for validation AND TypeScript types
- Built-in accessibility (aria attributes on errors)
- Works with any UI library
pnpm add react-hook-form zod @hookform/resolversFoundation: Schema-First Form Design
Always define the schema first. The TypeScript types come from it automatically.
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
// 1. Define the schema
const registrationSchema = z
.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(50, "Name is too long"),
email: z
.string()
.email("Enter a valid email address")
.toLowerCase(), // Transform: normalize email
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain an uppercase letter")
.regex(/[0-9]/, "Must contain a number")
.regex(/[^A-Za-z0-9]/, "Must contain a special character"),
confirmPassword: z.string(),
role: z.enum(["developer", "designer", "manager"], {
required_error: "Please select a role",
}),
agreeToTerms: z
.boolean()
.refine((v) => v === true, "You must agree to the terms"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"], // Which field to attach the error to
});
// 2. Derive the type from the schema
type RegistrationFormData = z.infer<typeof registrationSchema>;
// This is the same as:
// type RegistrationFormData = {
// name: string;
// email: string;
// password: string;
// confirmPassword: string;
// role: "developer" | "designer" | "manager";
// agreeToTerms: boolean;
// }
// 3. Build the form
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty, isValid },
watch,
reset,
setError,
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
mode: "onBlur", // Validate on blur (not on every keystroke)
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
agreeToTerms: false,
},
});
const onSubmit = async (data: RegistrationFormData) => {
try {
await registerUser(data);
reset(); // Clear form on success
} catch (err: any) {
if (err.code === "EMAIL_TAKEN") {
// Set server-side errors on specific fields
setError("email", { message: "This email is already registered" });
} else {
setError("root", { message: err.message ?? "Registration failed" });
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="space-y-4">
<Field label="Full Name" error={errors.name?.message}>
<input
{...register("name")}
type="text"
placeholder="Alice Johnson"
/>
</Field>
<Field label="Email" error={errors.email?.message}>
<input
{...register("email")}
type="email"
placeholder="alice@example.com"
/>
</Field>
<Field label="Password" error={errors.password?.message}>
<input {...register("password")} type="password" />
<PasswordStrengthIndicator password={watch("password")} />
</Field>
<Field label="Confirm Password" error={errors.confirmPassword?.message}>
<input {...register("confirmPassword")} type="password" />
</Field>
<Field label="Role" error={errors.role?.message}>
<select {...register("role")}>
<option value="">Select your role...</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
</select>
</Field>
<label className="flex items-start gap-3">
<input {...register("agreeToTerms")} type="checkbox" className="mt-1" />
<span>
I agree to the{" "}
<a href="/terms" className="text-blue-600 underline">Terms of Service</a>
</span>
</label>
{errors.agreeToTerms && (
<p className="text-red-600 text-sm">{errors.agreeToTerms.message}</p>
)}
{errors.root && (
<div className="bg-red-50 border border-red-200 text-red-700 p-3 rounded">
{errors.root.message}
</div>
)}
<button
type="submit"
disabled={isSubmitting || !isDirty}
className="w-full btn btn-primary"
>
{isSubmitting ? "Creating account..." : "Create Account"}
</button>
</div>
</form>
);
}Reusable Field Component
// Wrap every input with this — handles aria, labels, and errors
interface FieldProps {
label: string;
error?: string;
hint?: string;
required?: boolean;
children: React.ReactElement;
}
function Field({ label, error, hint, required, children }: FieldProps) {
const id = useId();
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
// Clone child to inject id and aria attributes
const input = React.cloneElement(children, {
id,
"aria-describedby": [error && errorId, hint && hintId]
.filter(Boolean)
.join(" ") || undefined,
"aria-invalid": error ? true : undefined,
});
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label}
{required && <span className="text-red-500 ml-1" aria-hidden>*</span>}
</label>
{input}
{hint && !error && (
<p id={hintId} className="text-sm text-gray-500 mt-1">{hint}</p>
)}
{error && (
<p id={errorId} className="text-sm text-red-600 mt-1" role="alert">
{error}
</p>
)}
</div>
);
}Multi-Step Form
const step1Schema = z.object({
firstName: z.string().min(1, "Required"),
lastName: z.string().min(1, "Required"),
email: z.string().email("Invalid email"),
});
const step2Schema = z.object({
company: z.string().min(1, "Required"),
role: z.string().min(1, "Required"),
size: z.enum(["1-10", "11-50", "51-200", "200+"]),
});
const step3Schema = z.object({
plan: z.enum(["free", "pro", "enterprise"]),
billingCycle: z.enum(["monthly", "annual"]).optional(),
coupon: z.string().optional(),
});
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type OnboardingData = z.infer<typeof fullSchema>;
function MultiStepOnboarding() {
const [step, setStep] = useState(1);
const form = useForm<OnboardingData>({
resolver: zodResolver(fullSchema),
mode: "onBlur",
defaultValues: {
firstName: "", lastName: "", email: "",
company: "", role: "", size: "1-10",
plan: "free",
},
});
const stepSchemas = [step1Schema, step2Schema, step3Schema];
const nextStep = async () => {
// Validate only the current step's fields
const currentSchema = stepSchemas[step - 1];
const fields = Object.keys(currentSchema.shape) as (keyof OnboardingData)[];
const valid = await form.trigger(fields);
if (valid) setStep((s) => s + 1);
};
const onSubmit = async (data: OnboardingData) => {
await createAccount(data);
};
return (
<div className="max-w-lg mx-auto">
{/* Progress indicator */}
<StepProgress current={step} total={3} />
<form onSubmit={form.handleSubmit(onSubmit)}>
{step === 1 && <Step1 form={form} />}
{step === 2 && <Step2 form={form} />}
{step === 3 && <Step3 form={form} />}
<div className="flex gap-3 mt-6">
{step > 1 && (
<button type="button" onClick={() => setStep((s) => s - 1)}>
Back
</button>
)}
{step < 3 ? (
<button type="button" onClick={nextStep}>
Next
</button>
) : (
<button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating..." : "Create Account"}
</button>
)}
</div>
</form>
</div>
);
}
function Step1({ form }: { form: UseFormReturn<OnboardingData> }) {
const { register, formState: { errors } } = form;
return (
<div className="space-y-4">
<h2>Personal Information</h2>
<Field label="First Name" error={errors.firstName?.message} required>
<input {...register("firstName")} type="text" />
</Field>
<Field label="Last Name" error={errors.lastName?.message} required>
<input {...register("lastName")} type="text" />
</Field>
<Field label="Email" error={errors.email?.message} required>
<input {...register("email")} type="email" />
</Field>
</div>
);
}Dynamic/Nested Fields with useFieldArray
const invoiceSchema = z.object({
clientName: z.string().min(1, "Required"),
lineItems: z.array(
z.object({
description: z.string().min(1, "Required"),
quantity: z.number().min(1, "Must be at least 1"),
unitPrice: z.number().min(0, "Cannot be negative"),
})
).min(1, "Add at least one item"),
});
type InvoiceFormData = z.infer<typeof invoiceSchema>;
function InvoiceForm() {
const { register, control, handleSubmit, watch, formState: { errors } } =
useForm<InvoiceFormData>({
resolver: zodResolver(invoiceSchema),
defaultValues: {
clientName: "",
lineItems: [{ description: "", quantity: 1, unitPrice: 0 }],
},
});
const { fields, append, remove, move } = useFieldArray({
control,
name: "lineItems",
});
const lineItems = watch("lineItems");
const total = lineItems.reduce(
(sum, item) => sum + (item.quantity || 0) * (item.unitPrice || 0),
0
);
return (
<form onSubmit={handleSubmit(console.log)}>
<Field label="Client Name" error={errors.clientName?.message} required>
<input {...register("clientName")} />
</Field>
<div className="mt-6">
<h3 className="font-medium mb-3">Line Items</h3>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-3 mb-3 items-start">
<Field
label={index === 0 ? "Description" : ""}
error={errors.lineItems?.[index]?.description?.message}
>
<input
{...register(`lineItems.${index}.description`)}
placeholder="Service description"
/>
</Field>
<Field
label={index === 0 ? "Qty" : ""}
error={errors.lineItems?.[index]?.quantity?.message}
>
<input
{...register(`lineItems.${index}.quantity`, { valueAsNumber: true })}
type="number"
min="1"
className="w-20"
/>
</Field>
<Field
label={index === 0 ? "Price" : ""}
error={errors.lineItems?.[index]?.unitPrice?.message}
>
<input
{...register(`lineItems.${index}.unitPrice`, { valueAsNumber: true })}
type="number"
min="0"
step="0.01"
className="w-28"
/>
</Field>
<div className={index === 0 ? "mt-6" : ""}>
<span className="text-sm text-gray-500 w-20 inline-block text-right">
${((lineItems[index]?.quantity || 0) * (lineItems[index]?.unitPrice || 0)).toFixed(2)}
</span>
<button
type="button"
onClick={() => remove(index)}
disabled={fields.length === 1}
className="ml-2 text-red-500 hover:text-red-700 disabled:opacity-30"
>
✕
</button>
</div>
</div>
))}
{errors.lineItems?.root && (
<p className="text-red-600 text-sm">{errors.lineItems.root.message}</p>
)}
<button
type="button"
onClick={() => append({ description: "", quantity: 1, unitPrice: 0 })}
className="text-sm text-blue-600 hover:underline"
>
+ Add Item
</button>
</div>
<div className="mt-4 text-right font-medium">
Total: ${total.toFixed(2)}
</div>
<button type="submit" className="mt-6 w-full btn btn-primary">
Send Invoice
</button>
</form>
);
}File Upload with Validation
const uploadSchema = z.object({
title: z.string().min(1, "Required"),
files: z
.instanceof(FileList)
.refine((files) => files.length > 0, "Select at least one file")
.refine(
(files) => Array.from(files).every((f) => f.size < 5 * 1024 * 1024),
"Each file must be under 5MB"
)
.refine(
(files) => Array.from(files).every((f) => ["image/jpeg", "image/png", "image/webp"].includes(f.type)),
"Only JPEG, PNG, and WebP images are allowed"
),
});
type UploadFormData = z.infer<typeof uploadSchema>;
function ImageUploadForm() {
const { register, handleSubmit, watch, formState: { errors } } =
useForm<UploadFormData>({ resolver: zodResolver(uploadSchema) });
const files = watch("files");
const previews = files
? Array.from(files).map((f) => URL.createObjectURL(f))
: [];
const onSubmit = async (data: UploadFormData) => {
const formData = new FormData();
formData.append("title", data.title);
Array.from(data.files).forEach((file) => formData.append("files", file));
await fetch("/api/upload", { method: "POST", body: formData });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Field label="Title" error={errors.title?.message}>
<input {...register("title")} />
</Field>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Images
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors">
<input
{...register("files")}
type="file"
multiple
accept="image/jpeg,image/png,image/webp"
className="sr-only"
id="file-upload"
/>
<label htmlFor="file-upload" className="cursor-pointer">
<p className="text-gray-600">Drop images here or <span className="text-blue-600">browse</span></p>
<p className="text-xs text-gray-400 mt-1">JPEG, PNG, WebP — max 5MB each</p>
</label>
</div>
{errors.files && (
<p className="text-sm text-red-600 mt-1">{errors.files.message}</p>
)}
</div>
{previews.length > 0 && (
<div className="flex gap-2 mt-3 flex-wrap">
{previews.map((url, i) => (
<img
key={i}
src={url}
alt={`Preview ${i + 1}`}
className="h-20 w-20 object-cover rounded"
/>
))}
</div>
)}
<button type="submit" className="mt-4 btn btn-primary">
Upload
</button>
</form>
);
}Async Validation (Email Availability Check)
const signupSchema = z.object({
email: z
.string()
.email("Invalid email")
.refine(
async (email) => {
// Only check availability if email is valid format
const res = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
const { available } = await res.json();
return available;
},
"This email is already registered"
),
password: z.string().min(8),
});
function SignupForm() {
const { register, handleSubmit, formState: { errors, isValidating } } =
useForm({
resolver: zodResolver(signupSchema),
mode: "onBlur", // Important: async validation runs on blur
});
return (
<form onSubmit={handleSubmit(console.log)}>
<Field label="Email" error={errors.email?.message}>
<div className="relative">
<input {...register("email")} type="email" />
{isValidating && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-500">
Checking...
</span>
)}
</div>
</Field>
<Field label="Password" error={errors.password?.message}>
<input {...register("password")} type="password" />
</Field>
<button type="submit">Sign Up</button>
</form>
);
}Controlled vs Uncontrolled — When to Use Controller
React Hook Form uses uncontrolled inputs by default (refs, not state). For custom components that don't accept ref, use Controller:
import { Controller } from "react-hook-form";
// Using a UI library component (e.g., a date picker, rich text editor, custom select)
function ProfileForm() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(console.log)}>
{/* React Hook Form controls the value and onChange */}
<Controller
name="birthDate"
control={control}
rules={{ required: "Birth date is required" }}
render={({ field, fieldState }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholderText="Select date"
error={fieldState.error?.message}
/>
)}
/>
<Controller
name="skills"
control={control}
render={({ field }) => (
<MultiSelect
value={field.value}
onChange={field.onChange}
options={skillOptions}
/>
)}
/>
</form>
);
}Interview Questions Answered
Q: What is the difference between controlled and uncontrolled components?
A controlled component has its value managed by React state — useState + onChange updates state which re-renders the input. An uncontrolled component stores its value in the DOM — accessed via ref.current.value. React Hook Form uses uncontrolled inputs for performance (no re-renders on keystroke) but gives you the values on submit.
Q: Why use Zod over Yup for validation?
Both are schema validation libraries. Zod has better TypeScript integration — types are inferred directly from schemas (z.infer<typeof schema>). Zod has a functional API that's more composable, and it's tree-shakeable. Yup is more mature and has wider community support. For new TypeScript projects, Zod is generally preferred.
Q: How does React Hook Form minimize re-renders?
RHF registers inputs as uncontrolled (via ref) and only reads values on submit or explicit calls to getValues(). The form state (errors, dirty, isSubmitting) is stored outside React state in a class, and only triggers re-renders when you actually access those values through formState. Contrast with controlled forms where every keystroke triggers setState → re-render.
Q: When would you use watch() and what are the performance implications?
watch() subscribes to field value changes and re-renders the component when the watched field updates. Use it when you need to: show dependent fields based on another field's value, display a live preview, or calculate a derived value (like the total in an invoice). For performance, watch specific fields (watch("email")) rather than the whole form (watch()).
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.