React cookbook · Intermediate
Validate forms with React Hook Form and Zod
Build a signup form with field-level errors, server error mapping and accessible messages.
Approach
Define a Zod schema (shared with the server if you use TypeScript there), connect it to React Hook Form via zodResolver, show errors linked with aria-describedby, and map server-side errors (e.g., "email taken") back to fields with setError.
Solution
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const signupSchema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(12, "Use at least 12 characters"),
confirm: z.string(),
}).refine((v) => v.password === v.confirm, { path: ["confirm"], message: "Passwords don't match" });
type SignupValues = z.infer<typeof signupSchema>;
export function SignupForm() {
const { register, handleSubmit, setError, formState: { errors, isSubmitting } } =
useForm<SignupValues>({ resolver: zodResolver(signupSchema) });
async function onSubmit(values: SignupValues) {
const res = await fetch("/api/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values) });
if (res.status === 409) return setError("email", { message: "An account with this email already exists" });
if (!res.ok) return setError("root", { message: "Something went wrong. Try again." });
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input id="email" type="email" autoComplete="email" aria-invalid={!!errors.email} aria-describedby="email-err" {...register("email")} />
{errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}
<label htmlFor="password">Password</label>
<input id="password" type="password" autoComplete="new-password" aria-invalid={!!errors.password} aria-describedby="pw-err" {...register("password")} />
{errors.password && <p id="pw-err" role="alert">{errors.password.message}</p>}
<label htmlFor="confirm">Confirm password</label>
<input id="confirm" type="password" autoComplete="new-password" aria-invalid={!!errors.confirm} aria-describedby="confirm-err" {...register("confirm")} />
{errors.confirm && <p id="confirm-err" role="alert">{errors.confirm.message}</p>}
{errors.root && <p role="alert">{errors.root.message}</p>}
<button disabled={isSubmitting}>{isSubmitting ? "Creating account…" : "Create account"}</button>
</form>
);
}
Pitfalls
- Relying on client validation only — the server must validate too.
- Errors shown only with color.
- Missing
autoCompleteattributes (password managers).
Variations
- React 19
useActionStatewith server-side validation for simpler forms. - Validate on blur (
mode: 'onBlur') for less noisy UX.
Further reading: React Hook Form: useForm
Reference: https://react-hook-form.com/docs/useform
More React recipes
- Add optimistic updates to a todo list
- Build an accessible modal dialog
- Build infinite scroll with TanStack Query
- Call a cookie-authenticated API with CSRF protection
- Debounce a search input in React
- Handle errors gracefully with error boundaries
- Implement dark mode that follows the system
- Poll a long-running job's status