React Development · Lesson 4 of 15
Forms & Validation
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()).