React Forms & Validation: React Hook Form and Zod in Production
Learn how to build robust, performant forms in React using React Hook Form and Zod — the combination we use on every B2B SaaS project at Atonize to handle validation, error states, and async submission without sacrificing performance.
React Forms & Validation: React Hook Form and Zod in Production
TL;DR — Key Takeaways
- Controlled forms with
useStatere-render on every keystroke — React Hook Form avoids this with uncontrolled inputs- Zod is a TypeScript-first schema validation library that gives you runtime validation and inferred types from a single source of truth
- Combining
react-hook-formwith@hookform/resolvers/zodgives you performant forms with declarative, type-safe validation- Always handle loading, error, and success states explicitly — especially in B2B contexts where form submission triggers critical business logic
- Server-side validation is still required — client-side validation is UX, not security
Why Form Management Is Harder Than It Looks
Forms seem simple. An input, a submit button, done. But production forms in B2B SaaS involve:
- Field-level and form-level validation with specific error messages
- Async validation (does this email already exist?)
- Disabling the submit button during submission
- Showing success and error states after submission
- Resetting the form after success
- TypeScript types for form values
Doing all of this with raw useState and useEffect means dozens of lines of boilerplate per form — and we covered in Custom React Hooks how repeated logic is a signal to abstract. React Hook Form and Zod are that abstraction, battle-tested at scale.
The Problem With Controlled Forms
The standard React approach to forms is controlled inputs — every input's value is tied to state:
// ❌ Re-renders on every single keystroke
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
<input value={email} onChange={(e) => setEmail(e.target.value)} />In a form with 10 fields, every keystroke triggers 10+ re-renders. For simple forms this is unnoticeable. For complex forms with real-time validation across many fields, it degrades performance measurably.
React Hook Form solves this by using uncontrolled inputs — it reads values from the DOM directly when needed, rather than syncing them into state on every change.
Setting Up React Hook Form with Zod
Install the dependencies:
npm install react-hook-form zod @hookform/resolversHere is a complete B2B contact/inquiry form — the kind you'd find on a SaaS pricing page or an RFQ (Request for Quote) system:
// components/InquiryForm.tsx
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Step 1: Define the schema with Zod
const inquirySchema = z.object({
fullName: z.string().min(2, "Full name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
company: z.string().min(1, "Company name is required"),
employees: z.enum(["1-10", "11-50", "51-200", "200+"], {
errorMap: () => ({ message: "Please select a company size" }),
}),
message: z.string().min(20, "Message must be at least 20 characters").max(1000),
});
// Step 2: Infer the TypeScript type from the schema
type InquiryFormValues = z.infer<typeof inquirySchema>;
export function InquiryForm() {
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
// Step 3: Initialize the form
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<InquiryFormValues>({
resolver: zodResolver(inquirySchema),
});
// Step 4: Handle submission
async function onSubmit(data: InquiryFormValues) {
try {
const res = await fetch("/api/inquiries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Submission failed");
setSubmitStatus("success");
reset();
} catch {
setSubmitStatus("error");
}
}
if (submitStatus === "success") {
return (
<div className="rounded-xl border border-green-200 bg-green-50 p-8 text-center">
<p className="text-lg font-semibold text-green-800">Inquiry received!</p>
<p className="mt-2 text-sm text-green-700">
We'll get back to you within one business day.
</p>
</div>
);
}
return (
<div onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{submitStatus === "error" && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Something went wrong. Please try again or contact us directly.
</div>
)}
<div className="grid gap-5 sm:grid-cols-2">
<Field label="Full Name" error={errors.fullName?.message}>
<input
{...register("fullName")}
placeholder="Ana Marković"
className={inputClass(!!errors.fullName)}
/>
</Field>
<Field label="Email Address" error={errors.email?.message}>
<input
{...register("email")}
type="email"
placeholder="ana@company.com"
className={inputClass(!!errors.email)}
/>
</Field>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<Field label="Company" error={errors.company?.message}>
<input
{...register("company")}
placeholder="Acme d.o.o."
className={inputClass(!!errors.company)}
/>
</Field>
<Field label="Company Size" error={errors.employees?.message}>
<select
{...register("employees")}
className={inputClass(!!errors.employees)}
>
<option value="">Select size...</option>
<option value="1-10">1–10 employees</option>
<option value="11-50">11–50 employees</option>
<option value="51-200">51–200 employees</option>
<option value="200+">200+ employees</option>
</select>
</Field>
</div>
<Field label="Message" error={errors.message?.message}>
<textarea
{...register("message")}
rows={5}
placeholder="Tell us about your project or requirements..."
className={inputClass(!!errors.message)}
/>
</Field>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Sending..." : "Send Inquiry"}
</button>
</div>
);
}
// Helper components
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700">{label}</label>
{children}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
}
function inputClass(hasError: boolean): string {
return [
"w-full rounded-lg border px-4 py-2.5 text-sm outline-none transition",
"focus:ring-2 focus:ring-blue-500 focus:ring-offset-0",
hasError
? "border-red-300 bg-red-50 focus:ring-red-400"
: "border-gray-300 bg-white focus:border-blue-500",
].join(" ");
}Why Zod + React Hook Form Is the Right Combination
The key insight is single source of truth for validation:
- You define the schema once in Zod
- Zod gives you runtime validation (catches bad data)
z.infer<typeof schema>gives you the TypeScript type automatically- The same schema can be used on the server (in your API route) for server-side validation
// app/api/inquiries/route.ts — same schema, same validation, server-side
import { inquirySchema } from "@/lib/schemas/inquiry";
export async function POST(req: Request) {
const body = await req.json();
const result = inquirySchema.safeParse(body);
if (!result.success) {
return Response.json({ errors: result.error.flatten() }, { status: 400 });
}
// result.data is fully typed and validated
await db.inquiries.create({ data: result.data });
return Response.json({ success: true });
}This is end-to-end type safety: the same Zod schema validates the form on the client and the API payload on the server.
Common Pitfalls
1. Skipping server-side validation
Client-side validation is a UX feature — it gives immediate feedback. It is not a security measure. Anyone can bypass it with a direct API call. Always validate on the server too, ideally with the same Zod schema.
2. Not handling all submission states
A common oversight is only handling success. Always handle all three:
// ✅ Cover all states
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");In B2B forms that trigger emails, CRM entries, or payment flows, an unhandled error state leaves users confused and support inboxes full.
3. Forgetting to reset after success
const { reset } = useForm();
async function onSubmit(data) {
await submitToAPI(data);
reset(); // ← don't forget this
}Without reset(), the form fields retain their values after submission — confusing for the user and a source of accidental resubmissions.
4. Overly generic error messages
// ❌ Useless
message: z.string().min(1, "Invalid")
// ✅ Actionable
message: z.string().min(20, "Message must be at least 20 characters")Error messages should tell the user exactly what to fix. In B2B contexts, forms are often filled by non-technical users — clarity matters.
Frequently Asked Questions (FAQ)
Why use React Hook Form instead of just useState?
React Hook Form avoids re-rendering on every keystroke by using uncontrolled inputs. It also provides a consistent API for registration, validation, error states, and form submission that would otherwise require significant custom code. For simple 2-3 field forms, useState is fine. For anything more complex, React Hook Form pays for itself immediately.
Can I use Zod without React Hook Form?
Absolutely. Zod is a standalone validation library. You can use it to validate API responses, parse environment variables, validate query parameters, or validate any data at runtime. The React Hook Form integration via @hookform/resolvers is just one of many use cases.
How do I handle server-returned validation errors?
React Hook Form's setError function lets you programmatically set errors on specific fields from your API response:
const { setError } = useForm();
// If the API returns { field: "email", message: "Already in use" }
setError("email", { message: "This email is already registered" });What about file uploads with React Hook Form?
File inputs work with register but require special handling since files can't be serialized to JSON. Use watch("fileField") to get the FileList, then append to FormData manually in your submit handler instead of sending JSON.
Series: React Masterclass
- 1
- 2
- 3
- 4
- 5
- 6
- 7React Forms & Validation: React Hook Form and Zod in Production (You are here)