Type-Safe React Forms with React Hook Form and Zod
Forms often start small and gradually become one of the most fragile parts of a frontend application. Validation rules spread across components, TypeScript types drift from runtime behavior, APIs return field-specific errors, and dynamic fields introduce another layer of state.
A reliable pattern is to make the schema the source of truth, let React Hook Form manage form state and interaction, and use Zod for runtime parsing and validation.
Why schema-first?
Independent types and validation rules eventually drift:
type ProfileForm = {
name: string
age: number
}
With Zod, the runtime model can also drive the static type:
import { z } from "zod"
const profileSchema = z.object({
name: z.string().trim().min(2),
age: z.coerce.number().int().min(18),
})
type ProfileForm = z.infer<typeof profileSchema>
When transforms or coercion make input and output different, Zod also exposes z.input and z.output.
Connect Zod to React Hook Form
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
const form = useForm<ProfileForm>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: "",
age: 18,
},
})
For simple schemas this is straightforward. When the schema transforms values, be deliberate about the raw input type and parsed output type.
Keep field errors near the field
<input
{...form.register("name")}
aria-invalid={Boolean(form.formState.errors.name)}
/>
{form.formState.errors.name?.message && (
<p role="alert">{form.formState.errors.name.message}</p>
)}
A field error should usually be rendered where the user can act on it. The same structure also improves accessibility.
Client validation is not a security boundary
A useful rule is:
Client validation = user experience
Server validation = trust boundary
The browser can be bypassed. The backend must validate untrusted input independently.
Map API errors back to fields
Some errors are impossible to know from the local schema, such as an email address already being registered:
form.setError("email", {
type: "server",
message: "This email is already registered",
})
Keep field-level errors attached to fields, while request-level failures should remain at form level.
Dynamic arrays
For repeatable fields such as phone numbers:
const schema = z.object({
phones: z.array(
z.object({
label: z.string().min(1),
value: z.string().min(7),
})
).min(1),
})
Then let React Hook Form own the array:
const phones = useFieldArray({
control: form.control,
name: "phones",
})
Avoid mirroring the same array in independent component state unless there is a strong UI reason.
Conditional forms belong in the model
When the data shape changes based on a discriminator, a discriminated union is usually cleaner than scattered if statements:
const schema = z.discriminatedUnion("accountType", [
z.object({
accountType: z.literal("personal"),
fullName: z.string().min(2),
}),
z.object({
accountType: z.literal("company"),
companyName: z.string().min(2),
taxId: z.string().min(5),
}),
])
Be selective with async validation
Username uniqueness and invitation codes may require network calls. Running those checks on every keystroke can create noisy UX and unnecessary traffic.
A better split is:
- structural validation locally;
- targeted async checks when they help UX;
- final server validation during submit.
Async refinements or transforms require asynchronous parsing.
Practical feature structure
features/profile-form/
├── profile.schema.ts
├── profile-form.tsx
├── profile-form.api.ts
└── profile-form.test.tsx
The goal is clear responsibility boundaries, not maximizing file count.
What should be tested?
Prefer behavioral coverage:
- invalid data does not submit;
- errors are visible;
- valid data reaches the handler;
- API field errors map correctly;
- dynamic rows can be added and removed.
Conclusion
React Hook Form and Zod are most useful when they create an architectural contract:
Zod Schema
↓
Types + Runtime Validation
↓
React Hook Form
↓
User Interaction
↓
API
↓
Server Validation
With a schema-driven model and a clear trust boundary, large forms become easier to reason about and refactor.