Forms: RHF + Zod
Schema-validated forms, error UX, async submission.
Forms are the part of React that everyone reinvents and nobody gets right the first time. Controlled inputs are easy until you have ten fields, async validation, async submission, and field-level errors. Then you reach for two libraries that have become the unofficial standard: React Hook Form for the wiring and Zod for the validation.
The pain RHF solves
Vanilla React forms put every input in state. Every keystroke re-renders the form. With ten inputs and complex validation, you notice. React Hook Form uses uncontrolled inputs under the hood: it lets the DOM hold the values and only re-renders when it absolutely has to (errors, submission state). The result is fast forms with a tiny API.
The basic shape: useForm + register
import { useForm } from "react-hook-form";
function SignupForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();
async function onSubmit(data) {
await createAccount(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email", { required: "Required" })} />
{errors.email && <p>{errors.email.message}</p>}
<button disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create"}
</button>
</form>
);
}Spread register("fieldName") onto each input. RHF wires up the ref, the onChange, the onBlur, and the value, all without making the input controlled. handleSubmit wraps your submit handler with validation and form-data collection.
Controller for custom components
registerworks for native HTML inputs. For custom components (a date picker, a select that doesn't expose a real input), use Controller:
import { Controller } from "react-hook-form";
<Controller
name="role"
control={control}
defaultValue="user"
render={({ field }) => <FancySelect {...field} options={ROLES} />}
/>field contains value, onChange,onBlur, name, and ref. Spread them onto your component however it expects.
Zod: the schema is your validator
Hand-written validation in form handlers gets ugly fast. Zod lets you describe your data's shape as a schema. The schema validates, the schema gives you TypeScript types, and the same schema can run on the server.
import { z } from "zod";
const SignupSchema = z.object({
email: z.string().email("Not a valid email"),
password: z.string().min(8, "At least 8 characters"),
age: z.coerce.number().int().min(13, "Must be 13 or older"),
});
type Signup = z.infer<typeof SignupSchema>;
// { email: string; password: string; age: number }Combine it with RHF via the @hookform/resolvers/zod adapter:
import { zodResolver } from "@hookform/resolvers/zod";
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(SignupSchema),
});Now errors.email, errors.password, etc., come straight from Zod's validation, with full messages.
SignupSchema.parse(input) before trusting it. One source of truth, zero drift.Field-level errors
formState.errors mirrors your schema: errors.email?.message, errors.address?.zip?.message for nested fields. Use them inline next to each input. Many UI libraries (shadcn/ui's Form, for instance) wrap this so you write even less code.
Async submission states
formState.isSubmitting is true while your submit handler is awaiting. Use it to disable the button and prevent double submits:
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</button>For server errors (the form was valid but the server rejected it), use setError("fieldName", { message: "..." }) after the request fails. The error shows up exactly like a client-side validation error.
Try it: a working signup form
Field validation runs on blur (try clicking out of a field with bad input). Submission shows pending state. Server "errors" are simulated for the password "hunter2".
Quiz
Why does React Hook Form avoid putting every input in state?
Recap
- React Hook Form keeps inputs uncontrolled so the form stays fast. Spread
register(name)on each input. - For non-native inputs, wrap with
Controller. - Zod describes your data with a schema, gives you TS types, and validates at runtime.
zodResolver(schema)connects the two and produces field-level errors.- Share the schema with your server (or server action) for one source of truth.
isSubmittingfor pending UI.setErrorfor server-side rejections.