A form is one of the most routine interface elements. A few fields and a button — or so it seems. Behind them, though, lies a real task: collect the input, validate it, show errors — and send it to the server in exactly the format it expects.
What's wrong with the naive approach
The first thing that comes to mind is storing each field in useState and checking everything manually:
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
function validate() {
if (!email.includes("@")) setEmailError("Invalid email");
// and so on for each field...
}
With 3–4 fields this is still manageable. With 10–15 fields it turns into a sprawl: as many state variables as there are fields, plus as many for errors, plus validation logic scattered across the file. Any change to a field re-renders the entire component — even if only one of fifteen fields changed.
The react-hook-form + zod combination approaches this differently: field management and validation rules live separately from useState, and each layer does its own job.
react-hook-form: fields without unnecessary re-renders
react-hook-form registers fields via register and treats them as uncontrolled — meaning it doesn't store each field's value in React state. The DOM holds the current value itself; the library reads it on submit. This means typing in one field doesn't re-render the others.
import { useForm } from "react-hook-form";
type LoginForm = { email: string; password: string };
function Login() {
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>();
const onSubmit = (data: LoginForm) => {
// data is typed — email and password are already strings
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register("password")} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Sign in</button>
</form>
);
}
handleSubmit intercepts the form submission, collects all field values, and calls your callback — but only if the form passed validation. Field errors are available via formState.errors.
zod: validation rules in one place
The useState approach hides validation rules inside functions — and they easily drift away from what the server actually accepts. zod lets you describe the shape of the data declaratively, as a schema:
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const loginSchema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(8, "Minimum 8 characters"),
});
type LoginForm = z.infer<typeof loginSchema>; // the type is inferred from the schema automatically
function Login() {
const { register, handleSubmit, formState: { errors } } =
useForm<LoginForm>({ resolver: zodResolver(loginSchema) });
// ...
}
z.infer<typeof schema> derives the TypeScript type directly from the schema — the type and the rules can't diverge because they are the same thing. zodResolver connects the schema to the form: on submit, react-hook-form runs the values through the schema and populates formState.errors with the messages from the schema.
A slightly more complex example — a product creation form:
const createProductSchema = z.object({
name: z.string().min(1, "Enter a name").max(200),
price: z.number().int().positive("Price must be greater than zero"),
category: z.enum(["electronics", "clothing", "food"]),
});
type CreateProductForm = z.infer<typeof createProductSchema>;
Server errors
Client-side validation catches obvious errors before submission — typos, empty required fields, wrong format. But the server can return errors that the client can't check in advance: "email already taken," "this SKU already exists," "insufficient permissions."
react-hook-form handles this via setError — you can set an error programmatically and it appears next to the relevant field:
const { register, handleSubmit, formState: { errors }, setError } = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginForm) => {
try {
await login(data);
} catch (err) {
if (err.code === "EMAIL_NOT_FOUND") {
setError("email", { message: "No user found with this email" });
}
}
};
This is an important architectural detail: client-side validation is for fast feedback, server-side validation is the source of truth. Both are needed: the client reacts instantly, the server guarantees correctness.
Numbers and type coercion
One non-obvious thing: HTML fields always return strings. If a field is <input type="number" />, the value in data will still arrive as a string — and zod will throw a type error if the schema expects z.number().
The fix is z.coerce.number(): zod converts the string to a number before validation:
const schema = z.object({
price: z.coerce.number().positive("Price must be greater than zero"),
quantity: z.coerce.number().int().min(1),
});
Default values
For edit forms — when you need to load existing data and let the user change it — useForm accepts defaultValues:
const { register, handleSubmit } = useForm<ProductForm>({
resolver: zodResolver(productSchema),
defaultValues: {
name: product.name,
price: product.price,
},
});
If the data loads asynchronously, you can pass defaultValues later via reset:
useEffect(() => {
if (product) reset(product);
}, [product]);
In short
- The naive approach (field →
useState, errors → separateuseState) grows proportionally with the number of fields. - react-hook-form works with uncontrolled fields — typing in one field doesn't re-render the others.
- zod describes validation rules declaratively;
z.inferderives the TypeScript type from the same schema. zodResolverconnects the schema to the form; schema errors flow intoformState.errors.- Server errors are set via
setError— they appear next to the relevant field. z.coerce.number()converts the string from<input>to a number before validation.- For edit forms —
defaultValueson creation orresetafter data loads. - Client-side validation is fast feedback; server-side is the source of truth. Both are needed.
What to read next
- Data fetching: TanStack Query — how to send form data as a mutation and handle the server response.
- Project structure — where to keep zod schemas and how they connect to request types.
- Routing: React Router — where to navigate after a successful form submission.