Type Safe Forms React with Hook Form and Zod
Type-Safe Forms in React Using React Hook Form and Zod
Forms look simple until they start running a real product.
A login form has two fields. A billing form has addresses, tax IDs, coupon logic, cardholder names, optional company fields, and country-specific rules. A SaaS onboarding form may change based on role, workspace size, plan type, or feature access. Before long, the form isn’t just a few inputs. It’s a contract between the browser, your TypeScript types, your validation rules, your API, and your database.
That’s where type safe forms React developers can trust become important.
In many React projects, form bugs come from one common problem: the form shape exists in too many places. You define an interface in TypeScript. Then you write validation rules somewhere else. Then you map API payloads manually. Then the backend expects a slightly different shape. One small mismatch, and the form still compiles, but the product breaks.
React Hook Form and Zod solve a large part of that problem when used together. React Hook Form manages form state and user interaction. Zod defines the validation schema and gives you TypeScript types from that schema. React Hook Form officially supports external validation libraries through resolvers, and the resolver package includes Zod support. (GitHub) Zod is a TypeScript-first schema validation library designed to validate unknown data and return typed results. (Zod)
This combination is practical, not just elegant. You get one schema that can validate form input, infer form types, drive error messages, and reduce duplicated logic.
Let’s build the full mental model.
Why Type-Safe Forms Matter in React
React gives you flexibility, but forms punish loose structure.
A normal form has many moving parts:
- Input values
- Validation rules
- Error messages
- Touched and dirty states
- Submit state
- API payload shape
- Server response handling
- Accessibility attributes
- Security boundaries
When those pieces are loosely connected, bugs become easy to miss.
For example, imagine this TypeScript type:
type SignupForm = {
email: string;
password: string;
companyName?: string;
};
Now imagine the validation logic says company instead of companyName:
if (!values.company) {
errors.company = "Company name is required";
}
TypeScript may not catch that if the validation object is loosely typed. The UI may render no error. The backend may reject the request. The user sees a vague failure.
This is exactly the kind of problem schema validation React workflows help prevent.
With Zod, the schema becomes the source of truth:
import { z } from "zod";
const signupSchema = z.object({
email: z.string().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
companyName: z.string().optional(),
});
type SignupFormValues = z.infer<typeof signupSchema>;
Now the form type comes from the validation schema. You don’t manually maintain a separate interface unless you have a good reason.
That’s the core idea behind type safe forms React teams can scale.
What React Hook Form Does Well
React Hook Form is a form state management library for React. Its main job is to help you register inputs, track field state, validate values, handle submission, and display errors with less boilerplate than fully manual form state management.
The important part is that React Hook Form can work with validation resolvers. A resolver lets you connect a schema validation library, such as Zod, to the form lifecycle. (React Hook Form)
In practical terms, React Hook Form handles:
- Registering fields
- Tracking form values
- Tracking validation errors
- Handling submit events
- Managing dirty, touched, valid, and submitting states
- Integrating with controlled components through
Controller - Connecting to external validators through resolvers
That means you don’t need to write a custom useState handler for every input.
A simple form can be built like this:
import { useForm } from "react-hook-form";
type LoginFormValues = {
email: string;
password: string;
};
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>();
const onSubmit = async (values: LoginFormValues) => {
console.log(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>
Sign in
</button>
</form>
);
}
This is already better than manually managing each field. But by itself, it doesn’t give you schema-level type safety. That’s where Zod becomes useful.
What Zod Adds to React Form Validation
Zod gives you a schema that exists at runtime and also supports TypeScript inference.
That distinction matters.
TypeScript helps during development, but TypeScript types disappear at runtime. A user can still submit bad data. A browser extension can modify input. A malicious actor can send a request without using your frontend at all. An API can return unexpected data. TypeScript alone doesn’t validate runtime input.
Zod fills that gap.
A Zod schema can define the expected shape:
const profileSchema = z.object({
fullName: z.string().min(2),
email: z.string().email(),
age: z.coerce.number().int().min(18),
});
Then TypeScript can infer the type:
type ProfileFormValues = z.infer<typeof profileSchema>;
This gives you two benefits from one source:
- Runtime validation
- Static TypeScript types
Zod’s documentation describes this pattern directly: define a schema, validate unknown data, and get a typed result after parsing. (Zod)
For forms, that means your validation rules and your form data type can stay aligned.
Why React Hook Form and Zod Work So Well Together
React Hook Form and Zod solve different problems.
React Hook Form manages the form experience. Zod validates the data shape.
Together, they create a clean pipeline:
- User enters values.
- React Hook Form collects and tracks those values.
- Zod validates the values through the resolver.
- React Hook Form exposes validation errors.
- Your submit handler receives typed data.
Here is the common setup:
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const signupSchema = z.object({
email: z.string().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type SignupFormValues = z.infer<typeof signupSchema>;
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupFormValues>({
resolver: zodResolver(signupSchema),
});
const onSubmit = async (values: SignupFormValues) => {
console.log(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <p role="alert">{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
Create account
</button>
</form>
);
}
The important line is this:
resolver: zodResolver(signupSchema)
That connects Zod validation to React Hook Form. The official resolver package is designed for this kind of integration. (GitHub)
Installing React Hook Form, Zod, and the Resolver
For most React projects, you need three packages:
npm install react-hook-form zod @hookform/resolvers
Or with pnpm:
pnpm add react-hook-form zod @hookform/resolvers
Or with yarn:
yarn add react-hook-form zod @hookform/resolvers
The package roles are simple:
| Package | Purpose |
|---|---|
react-hook-form | Handles form state, registration, submission, and errors |
zod | Defines and validates schemas |
@hookform/resolvers | Connects Zod and other schema libraries to React Hook Form |
You can use React Hook Form without Zod. You can also use Zod without React Hook Form. The combination is useful because it keeps validation, types, and form state connected.
Building a Type-Safe Signup Form
Let’s build a more realistic signup form.
Requirements:
- Email must be valid.
- Password must be at least 8 characters.
- Confirm password must match password.
- Terms must be accepted.
- Company name is optional.
- User role must be selected.
Here is the schema:
import { z } from "zod";
export const signupSchema = z
.object({
email: z.string().trim().email("Enter a valid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
role: z.enum(["founder", "developer", "marketer", "other"], {
message: "Choose a role",
}),
companyName: z.string().trim().optional(),
acceptTerms: z.literal(true, {
message: "You must accept the terms",
}),
})
.refine((values) => values.password === values.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export type SignupFormValues = z.infer<typeof signupSchema>;
The refine method handles validation that depends on multiple fields. Password confirmation is a common example.
Now connect it to React Hook Form:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, type SignupFormValues } from "./signup-schema";
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
} = useForm<SignupFormValues>({
resolver: zodResolver(signupSchema),
mode: "onBlur",
});
const onSubmit = async (values: SignupFormValues) => {
const payload = {
email: values.email,
password: values.password,
role: values.role,
companyName: values.companyName || null,
};
await fetch("/api/signup", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">Work email</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="new-password"
aria-invalid={Boolean(errors.password)}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p id="password-error" role="alert">
{errors.password.message}
</p>
)}
</div>
<div>
<label htmlFor="confirmPassword">Confirm password</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
aria-invalid={Boolean(errors.confirmPassword)}
aria-describedby={
errors.confirmPassword ? "confirm-password-error" : undefined
}
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p id="confirm-password-error" role="alert">
{errors.confirmPassword.message}
</p>
)}
</div>
<div>
<label htmlFor="role">Your role</label>
<select
id="role"
aria-invalid={Boolean(errors.role)}
aria-describedby={errors.role ? "role-error" : undefined}
{...register("role")}
>
<option value="">Select a role</option>
<option value="founder">Founder</option>
<option value="developer">Developer</option>
<option value="marketer">Marketer</option>
<option value="other">Other</option>
</select>
{errors.role && (
<p id="role-error" role="alert">
{errors.role.message}
</p>
)}
</div>
<div>
<label htmlFor="companyName">Company name</label>
<input
id="companyName"
type="text"
autoComplete="organization"
{...register("companyName")}
/>
</div>
<div>
<label htmlFor="acceptTerms">
<input id="acceptTerms" type="checkbox" {...register("acceptTerms")} />
I accept the terms
</label>
{errors.acceptTerms && (
<p role="alert">{errors.acceptTerms.message}</p>
)}
</div>
<button type="submit" disabled={isSubmitting || !isValid}>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</form>
);
}
This form is type-safe because the form values are inferred from the schema. If you rename companyName in the schema, TypeScript will warn you where the old field is still used.
That’s the payoff.
A Better Folder Structure for SaaS Forms
For small apps, keeping the schema and component in one file is fine. For SaaS apps, forms usually grow. You may reuse the same schema in a modal, settings page, onboarding flow, or API route.
A cleaner structure looks like this:
features/
signup/
components/
signup-form.tsx
schemas/
signup-schema.ts
actions/
submit-signup.ts
types/
signup-types.ts
But don’t split files just to look “enterprise.” Split when it improves clarity.
A practical structure could be:
features/
billing/
billing-form.tsx
billing-schema.ts
billing-api.ts
This keeps related logic close together while avoiding one giant component.
For larger teams, the schema file is especially valuable. It becomes the shared contract for the form. Designers can review field requirements. Backend engineers can compare API expectations. Frontend developers can reuse the inferred type.
Handling Optional, Nullable, and Empty String Values
Form inputs often return empty strings. Your API may expect null, undefined, or a missing field instead.
This is where many React form validation bugs appear.
Consider this field:
companyName: z.string().optional()
That means companyName can be a string or undefined. But an empty input usually gives you an empty string, not undefined.
So the submitted value may be:
{
companyName: ""
}
If your backend expects null, you need to normalize it.
One option is to normalize during submit:
const payload = {
companyName: values.companyName?.trim() || null,
};
Another option is to preprocess with Zod:
const emptyStringToUndefined = z.preprocess((value) => {
if (typeof value === "string" && value.trim() === "") {
return undefined;
}
return value;
}, z.string().optional());
Then use it:
const schema = z.object({
companyName: emptyStringToUndefined,
});
This is useful for optional fields such as:
- Company name
- Website URL
- Phone number
- Address line 2
- Promo code
- Internal notes
The key is to decide what your application means by “empty” and handle it consistently.
TypeScript Form Validation Without Duplicated Types
One of the biggest benefits of React Hook Form Zod workflows is reducing duplicated type definitions.
Without Zod inference, developers often write this:
type SettingsFormValues = {
displayName: string;
emailNotifications: boolean;
};
const settingsSchema = z.object({
displayName: z.string().min(2),
emailNotifications: z.boolean(),
});
This looks harmless, but it creates two sources of truth. If one changes and the other doesn’t, you have a silent mismatch.
A better pattern is:
const settingsSchema = z.object({
displayName: z.string().min(2),
emailNotifications: z.boolean(),
});
type SettingsFormValues = z.infer<typeof settingsSchema>;
Now the type follows the schema.
For implementation-heavy developer work, this is usually the cleanest approach. Your schema defines the rules. TypeScript follows those rules. React Hook Form receives the inferred type.
Form Validation React Developers Should Handle Carefully
Not every validation rule belongs in the same place.
A good form validation React setup usually has three layers:
| Layer | Example | Purpose |
|---|---|---|
| HTML attributes | type="email", required, minLength | Basic browser-level hints |
| Client schema validation | Zod with React Hook Form | User feedback before submit |
| Server validation | API-side validation | Security and data integrity |
Client-side validation improves user experience, but it is not a security boundary. Any data sent to a server should be validated again on the server.
That matters for secure form handling.
A user can bypass your React app and send a direct API request. So even if your frontend uses Zod perfectly, your backend still needs validation, authorization, rate limiting, and safe error handling where appropriate.
For full-stack TypeScript apps, you can sometimes share Zod schemas between frontend and backend. That can reduce duplication, but only when the shared schema truly matches both contexts. Sometimes the frontend form schema and backend API schema should be different.
For example, a signup form may include:
confirmPassword: z.string()
But the backend account creation payload should not need confirmPassword. It only needs the final password after the frontend confirms the two fields match.
So you may have:
const signupFormSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
});
const signupApiSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
Similar, but not identical.
That distinction keeps your application honest.
Handling Numbers in React Hook Form and Zod
HTML input values usually arrive as strings. Even if your input is type="number", the value often needs conversion.
React Hook Form supports options such as valueAsNumber, but you can also handle coercion with Zod.
Example:
const planSchema = z.object({
seats: z.coerce
.number()
.int("Seats must be a whole number")
.min(1, "Choose at least 1 seat")
.max(500, "Contact sales for larger teams"),
});
Then:
<input
id="seats"
type="number"
min={1}
max={500}
{...register("seats")}
/>
This is cleaner than manually converting the value in your submit handler.
But be careful. Coercion can hide bad assumptions if you don’t test edge cases. Empty strings, whitespace, and invalid values should be checked against your intended behavior.
For pricing, seats, quantities, usage limits, and billing fields, it’s worth writing unit tests around the schema.
Validating Dates and Time Ranges
Date forms are another common trap.
A simple date field may look like this:
const eventSchema = z.object({
startDate: z.string().min(1, "Choose a start date"),
endDate: z.string().min(1, "Choose an end date"),
});
That validates presence, but not date logic.
For a booking form, trial period, contract term, or scheduled campaign, you may need cross-field validation:
const dateRangeSchema = z
.object({
startDate: z.string().min(1, "Choose a start date"),
endDate: z.string().min(1, "Choose an end date"),
})
.refine(
(values) => {
const start = new Date(values.startDate);
const end = new Date(values.endDate);
return end >= start;
},
{
message: "End date must be after the start date",
path: ["endDate"],
}
);
This is a strong use case for Zod because the rule belongs to the form data as a whole, not one isolated input.
Still, date handling can get complex. Time zones, locale formats, daylight saving changes, and backend storage rules can affect behavior. For serious scheduling flows, define date rules clearly across frontend and backend.
Controlled Components with Controller
Many SaaS apps use component libraries. Selects, date pickers, comboboxes, sliders, and rich text inputs may not behave like native inputs.
React Hook Form provides Controller for controlled components.
Example:
import { Controller, useForm } from "react-hook-form";
type FormValues = {
plan: "starter" | "pro" | "enterprise";
};
export function PlanForm() {
const { control, handleSubmit } = useForm<FormValues>({
defaultValues: {
plan: "starter",
},
});
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="plan"
control={control}
render={({ field }) => (
<select value={field.value} onChange={field.onChange}>
<option value="starter">Starter</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
)}
/>
<button type="submit">Continue</button>
</form>
);
}
When using a UI library, the exact props may differ. Some components call the change handler onValueChange instead of onChange. Some return an object instead of a string. Some require separate selected and onSelect props.
The important rule is this: make the component’s value shape match your Zod schema.
If your schema expects:
plan: z.enum(["starter", "pro", "enterprise"])
Don’t let the UI submit:
{ label: "Pro", value: "pro" }
unless your schema expects that object.
Default Values and Form Reset Behavior
Default values matter in type safe forms.
React Hook Form can use defaultValues:
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
defaultValues: {
displayName: "",
emailNotifications: true,
},
});
For edit forms, default values usually come from an API:
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
defaultValues: {
displayName: "",
emailNotifications: true,
},
});
Then after loading data:
useEffect(() => {
if (userSettings) {
form.reset({
displayName: userSettings.displayName,
emailNotifications: userSettings.emailNotifications,
});
}
}, [userSettings, form]);
This is common in account settings pages, profile editors, admin dashboards, and SaaS configuration screens.
Be careful with partial API data. If the API can return null, but your form expects a string, normalize before calling reset.
Example:
form.reset({
displayName: userSettings.displayName ?? "",
emailNotifications: Boolean(userSettings.emailNotifications),
});
This keeps your form controlled and predictable.
Error Messages That Help Users
A type-safe form is not automatically a user-friendly form.
Bad error message:
Invalid input
Better error message:
Enter a valid email address.
Bad error message:
String must contain at least 8 character(s)
Better error message:
Password must be at least 8 characters.
Zod lets you define clear messages inside the schema:
const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.max(72, "Password is too long");
For SaaS products, good validation messages reduce support friction. Users should know what went wrong and how to fix it.
For accessibility, connect the input and error message:
<input
id="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
That small detail helps assistive technologies connect the field with the validation message.
Secure Form Handling in React
Secure form handling starts in the frontend, but it does not end there.
Client-side validation can:
- Reduce accidental bad input
- Improve user experience
- Prevent avoidable failed requests
- Keep form data structured before submission
But it cannot:
- Prove user identity
- Enforce authorization
- Stop direct API abuse
- Replace backend validation
- Protect secrets placed in frontend code
Never put private API keys, admin tokens, or trusted business rules only in React code. Anything shipped to the browser should be treated as visible to the user.
A safer flow looks like this:
- Validate the form with Zod on the client.
- Send only the required payload fields.
- Validate the payload again on the server.
- Check authentication and authorization.
- Return safe error messages.
- Log server-side failures carefully without exposing sensitive data to the client.
Example submit handler:
const onSubmit = async (values: SignupFormValues) => {
const response = await fetch("/api/signup", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: values.email,
password: values.password,
role: values.role,
}),
});
if (!response.ok) {
throw new Error("Signup failed");
}
};
This example keeps the payload focused. It does not send confirmPassword because the server does not need it.
For production apps, you may also need CSRF protection, bot mitigation, rate limiting, audit logs, secure session handling, and backend-side schema validation. The exact requirements depend on your stack and risk level.
Mapping Server Errors Back to Fields
Frontend validation catches predictable issues. Server validation catches final truth.
Sometimes the server returns a field-level error:
{
"fieldErrors": {
"email": "This email is already registered."
}
}
React Hook Form lets you set field errors manually:
const {
setError,
handleSubmit,
register,
formState: { errors },
} = useForm<SignupFormValues>({
resolver: zodResolver(signupSchema),
});
const onSubmit = async (values: SignupFormValues) => {
const response = await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(values),
});
if (response.status === 409) {
setError("email", {
type: "server",
message: "This email is already registered.",
});
return;
}
if (!response.ok) {
setError("root", {
type: "server",
message: "Something went wrong. Try again.",
});
return;
}
};
Then render a root-level error:
{errors.root && <p role="alert">{errors.root.message}</p>}
This pattern is useful for:
- Duplicate email
- Invalid invite code
- Expired coupon
- Permission denied
- Plan limit reached
- Server-side business rule failure
Don’t force every server error into a field. Some errors belong at the form level.
Creating Reusable Form Field Components
Once your form grows, repeated label-input-error blocks become noisy.
You can create a reusable field component:
type TextFieldProps = {
id: string;
label: string;
error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;
export function TextField({ id, label, error, ...props }: TextFieldProps) {
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={Boolean(error)}
aria-describedby={error ? `${id}-error` : undefined}
{...props}
/>
{error && (
<p id={`${id}-error`} role="alert">
{error}
</p>
)}
</div>
);
}
Use it like this:
<TextField
id="email"
label="Email"
type="email"
error={errors.email?.message}
{...register("email")}
/>
This keeps the form readable without hiding too much logic.
Be careful not to over-abstract. If your field component becomes a maze of props, it may be harder to maintain than plain JSX.
A good reusable field component should make common cases easier while still allowing direct control when needed.
Multi-Step Forms with React Hook Form and Zod
Multi-step forms are common in SaaS products:
- Account setup
- Workspace creation
- Billing setup
- Team invite flow
- Product onboarding
- Loan, insurance, or compliance workflows
You have two main options.
Option 1: One Schema for the Whole Form
This works when the full data shape is known from the start:
const onboardingSchema = z.object({
account: z.object({
email: z.string().email(),
password: z.string().min(8),
}),
workspace: z.object({
workspaceName: z.string().min(2),
teamSize: z.coerce.number().min(1),
}),
billing: z.object({
plan: z.enum(["starter", "pro"]),
}),
});
This is useful when you submit everything at the end.
Option 2: One Schema Per Step
This works when each step has its own validation:
const accountStepSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const workspaceStepSchema = z.object({
workspaceName: z.string().min(2),
teamSize: z.coerce.number().min(1),
});
This is easier to reason about when users can save progress, skip steps, or return later.
For complex flows, step-level schemas are often cleaner. They also make it easier to show precise validation errors at the right time.
Conditional Fields and Dynamic Validation
Many SaaS forms change based on user choices.
Example: if a user selects “company,” require company name. If they select “individual,” don’t.
const customerSchema = z
.object({
accountType: z.enum(["individual", "company"]),
companyName: z.string().optional(),
})
.refine(
(values) => {
if (values.accountType === "company") {
return Boolean(values.companyName?.trim());
}
return true;
},
{
message: "Company name is required for company accounts",
path: ["companyName"],
}
);
This keeps the rule close to the data.
In the component, you can watch the controlling field:
const accountType = watch("accountType");
{accountType === "company" && (
<TextField
id="companyName"
label="Company name"
error={errors.companyName?.message}
{...register("companyName")}
/>
)}
Conditional validation can get messy if every field depends on every other field. When that happens, pause and reconsider the form design. Sometimes the better solution is separate steps or separate schemas.
Schema Validation React Patterns for API Payloads
A common mistake is submitting the entire form object directly to the API.
That can leak fields the server does not need.
Example form values:
type SignupFormValues = {
email: string;
password: string;
confirmPassword: string;
acceptTerms: true;
marketingOptIn?: boolean;
};
The API payload may only need:
{
email: string;
password: string;
marketingOptIn?: boolean;
}
So map the payload intentionally:
const payload = {
email: values.email,
password: values.password,
marketingOptIn: Boolean(values.marketingOptIn),
};
This is not busywork. It creates a boundary between UI state and API contracts.
In larger codebases, you may create a function:
function toSignupPayload(values: SignupFormValues) {
return {
email: values.email,
password: values.password,
marketingOptIn: Boolean(values.marketingOptIn),
};
}
Then your submit handler becomes easier to test.
Testing Zod Schemas
You don’t need to test React Hook Form internals. But you should test your schema rules when they contain business logic.
Example with a password confirmation schema:
describe("signupSchema", () => {
it("rejects mismatched passwords", () => {
const result = signupSchema.safeParse({
email: "user@example.com",
password: "password123",
confirmPassword: "different123",
role: "developer",
acceptTerms: true,
});
expect(result.success).toBe(false);
});
it("accepts valid signup data", () => {
const result = signupSchema.safeParse({
email: "user@example.com",
password: "password123",
confirmPassword: "password123",
role: "developer",
acceptTerms: true,
});
expect(result.success).toBe(true);
});
});
Schema tests are especially useful for:
- Conditional required fields
- Numeric ranges
- Date ranges
- Role-based settings
- Billing rules
- Multi-step forms
- Payload transformation
These tests are fast, focused, and easier to maintain than full UI tests for every validation branch.
Common Mistakes With React Hook Form and Zod
Even experienced developers make avoidable mistakes.
Mistake 1: Duplicating Types Manually
Avoid this:
type FormValues = {
email: string;
};
const schema = z.object({
email: z.string().email(),
});
Prefer this:
const schema = z.object({
email: z.string().email(),
});
type FormValues = z.infer<typeof schema>;
Mistake 2: Trusting Frontend Validation Too Much
Frontend validation improves UX. It does not secure your backend.
Always validate important data server-side.
Mistake 3: Sending UI-Only Fields to the API
Fields like confirmPassword, temporary toggles, local UI state, and wizard progress often should not be sent to the backend.
Map your payload intentionally.
Mistake 4: Poor Error Accessibility
Showing red text is not enough. Use labels, aria-invalid, aria-describedby, and clear error text where appropriate.
Mistake 5: Overusing watch
watch is useful, but excessive use can make forms harder to reason about. For complex dependencies, consider smaller components, step-level schemas, or clearer state boundaries.
Mistake 6: Ignoring Empty Strings
Optional form fields often submit empty strings. Decide whether empty means "", null, or undefined.
Mistake 7: Using One Giant Schema for Everything
One massive schema can become hard to maintain. Split schemas when the product flow has clear boundaries.
When React Hook Form and Zod May Not Be the Best Fit
React Hook Form Zod is a strong default for many React and TypeScript apps, but it is not the only valid approach.
You may choose another setup if:
- Your team already uses another schema library consistently.
- Your form is extremely simple.
- Your design system has its own form abstraction.
- Your backend schema system is already the source of truth.
- Your app uses a framework with built-in form conventions that fit your needs.
The point is not to force one stack into every project. The point is to reduce mismatches between validation, types, UI state, and API contracts.
For many SaaS frontend teams, React Hook Form and Zod hit a practical balance: strong typing, clear schemas, good ergonomics, and manageable boilerplate.
Best Practices for Type Safe Forms React Teams Can Maintain
Here is a practical checklist:
| Area | Best practice |
|---|---|
| Schema | Use Zod as the source of truth for form shape |
| Types | Infer TypeScript types with z.infer |
| Validation | Connect Zod with zodResolver |
| Errors | Write human-friendly error messages |
| Accessibility | Connect fields and errors with ARIA attributes |
| Security | Validate again on the server |
| Payloads | Map form values to API payloads intentionally |
| Defaults | Normalize API data before calling reset |
| Testing | Unit test schemas with business rules |
| Structure | Keep schemas close to their feature |
A clean form system should make correct code easier to write than incorrect code.
That’s the real value.
A Complete Example: Account Settings Form
Let’s finish with a realistic account settings form.
Schema:
import { z } from "zod";
export const accountSettingsSchema = z.object({
displayName: z
.string()
.trim()
.min(2, "Display name must be at least 2 characters")
.max(80, "Display name is too long"),
email: z.string().trim().email("Enter a valid email address"),
timezone: z.string().min(1, "Choose a timezone"),
emailNotifications: z.boolean(),
weeklySummary: z.boolean(),
});
export type AccountSettingsFormValues = z.infer<
typeof accountSettingsSchema
>;
Form component:
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
accountSettingsSchema,
type AccountSettingsFormValues,
} from "./account-settings-schema";
type AccountSettingsFormProps = {
initialValues: {
displayName: string | null;
email: string;
timezone: string | null;
emailNotifications: boolean | null;
weeklySummary: boolean | null;
};
};
export function AccountSettingsForm({
initialValues,
}: AccountSettingsFormProps) {
const {
register,
reset,
setError,
handleSubmit,
formState: { errors, isDirty, isSubmitting },
} = useForm<AccountSettingsFormValues>({
resolver: zodResolver(accountSettingsSchema),
defaultValues: {
displayName: "",
email: "",
timezone: "",
emailNotifications: true,
weeklySummary: false,
},
});
useEffect(() => {
reset({
displayName: initialValues.displayName ?? "",
email: initialValues.email,
timezone: initialValues.timezone ?? "",
emailNotifications: Boolean(initialValues.emailNotifications),
weeklySummary: Boolean(initialValues.weeklySummary),
});
}, [initialValues, reset]);
const onSubmit = async (values: AccountSettingsFormValues) => {
const response = await fetch("/api/account/settings", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
displayName: values.displayName,
email: values.email,
timezone: values.timezone,
emailNotifications: values.emailNotifications,
weeklySummary: values.weeklySummary,
}),
});
if (response.status === 409) {
setError("email", {
type: "server",
message: "This email is already used by another account.",
});
return;
}
if (!response.ok) {
setError("root", {
type: "server",
message: "Your settings could not be saved. Try again.",
});
return;
}
reset(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{errors.root && <p role="alert">{errors.root.message}</p>}
<div>
<label htmlFor="displayName">Display name</label>
<input
id="displayName"
type="text"
autoComplete="name"
aria-invalid={Boolean(errors.displayName)}
aria-describedby={
errors.displayName ? "display-name-error" : undefined
}
{...register("displayName")}
/>
{errors.displayName && (
<p id="display-name-error" role="alert">
{errors.displayName.message}
</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="timezone">Timezone</label>
<select
id="timezone"
aria-invalid={Boolean(errors.timezone)}
aria-describedby={errors.timezone ? "timezone-error" : undefined}
{...register("timezone")}
>
<option value="">Choose a timezone</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="UTC">UTC</option>
</select>
{errors.timezone && (
<p id="timezone-error" role="alert">
{errors.timezone.message}
</p>
)}
</div>
<div>
<label htmlFor="emailNotifications">
<input
id="emailNotifications"
type="checkbox"
{...register("emailNotifications")}
/>
Send important email notifications
</label>
</div>
<div>
<label htmlFor="weeklySummary">
<input
id="weeklySummary"
type="checkbox"
{...register("weeklySummary")}
/>
Send weekly summary
</label>
</div>
<button type="submit" disabled={isSubmitting || !isDirty}>
{isSubmitting ? "Saving..." : "Save settings"}
</button>
</form>
);
}
This example shows the main pattern:
- The schema defines the rules.
- The TypeScript type comes from the schema.
- React Hook Form manages state.
- The resolver connects validation.
- Server errors map back into the form.
- The API payload is explicit.
- Accessibility is considered at the field level.
That is how type-safe form architecture becomes practical.
Conclusion: Type-Safe Forms React Teams Can Actually Ship
Type-safe forms in React are not about adding complexity for the sake of it. They are about removing hidden mismatch.
React Hook Form gives you a clean way to manage form state. Zod gives you runtime schema validation and TypeScript inference. Together, they help frontend developers build forms that are easier to refactor, easier to test, and harder to break accidentally.
For SaaS product engineers, this matters even more. Forms are often tied to revenue, onboarding, billing, permissions, account settings, and support workflows. A small form bug can create failed signups, bad data, or user frustration.
The best approach is straightforward:
Use Zod as the source of truth. Infer your TypeScript types from the schema. Connect the schema to React Hook Form with the Zod resolver. Normalize values deliberately. Validate again on the server. Keep error messages clear. And don’t send more data to your API than it needs.
That’s the foundation for type safe forms React applications can rely on as they grow.
FAQs
What are type-safe forms in React?
Type-safe forms in React are forms where the expected data shape is connected to TypeScript types and validation rules. Instead of manually keeping types and validation logic separate, you can use a schema library like Zod to define the form shape and infer the TypeScript type from it.
Why use React Hook Form with Zod?
React Hook Form manages form state, submission, and errors, while Zod validates the data shape. When used together through the Zod resolver, they create a cleaner workflow for TypeScript form validation and reduce duplicated form types.
Is Zod required for React Hook Form?
No. React Hook Form can work without Zod. You can use built-in validation rules or another validation library. Zod is useful when you want schema validation, runtime validation, and TypeScript type inference from the same source.
Does TypeScript replace form validation?
No. TypeScript helps during development, but it does not validate user input at runtime. Form validation still needs runtime checks. Zod helps by validating actual submitted values, not just compile-time types.
Should I validate React form data on the server too?
Yes. Client-side validation is useful for user experience, but it should not be treated as a security boundary. Important data should be validated again on the server before it is trusted, stored, or used in business logic.
How do I handle server errors with React Hook Form?
Use React Hook Form’s setError method. Field-specific server errors can be attached to fields such as email, while general errors can be attached to root and displayed near the submit button or top of the form.
What is the best way to avoid duplicated form types?
Define your Zod schema first, then infer the TypeScript type with z.infer<typeof schema>. This keeps the schema and the TypeScript form values aligned.
Can I use React Hook Form and Zod for multi-step forms?
Yes. You can use one schema for the full form or separate schemas for each step. Step-level schemas are often easier to maintain when each step has different validation rules or can be saved separately.
How should optional fields be handled in Zod forms?
Be careful with empty strings. Optional fields in TypeScript may be undefined, but browser inputs often return empty strings. Normalize empty values in your schema or submit handler so your API receives the expected format.
Is React Hook Form Zod a good choice for SaaS apps?
Yes, it is a strong choice for many SaaS apps because it supports typed data, schema validation, reusable form logic, and clear error handling. It is especially useful for signup flows, onboarding, billing settings, account forms, and admin dashboards.