React Hook Form + Zod: the form pattern that's become a team standard
Uncontrolled inputs, schema-defined validation, TypeScript types inferred from the schema. Here's the full setup, cross-field validation, async checks, and multi-step forms.
React Hook Form and Zod have become the settled pair for form handling in TypeScript React apps the way eslint and prettier settled for linting. RHF manages form state with uncontrolled inputs (minimal re-renders), Zod defines the schema (validation rules and TypeScript types in one place), and @hookform/resolvers connects them with a single line of config.
The base setup
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type FormValues = z.infer<typeof schema>; // type from schema โ no duplication
function SignupForm() {
const { register, handleSubmit, formState: { errors } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
const onSubmit = (data: FormValues) => { /* data is fully typed */ };
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Sign up</button>
</form>
);
}Async validation
For validation requiring a network call โ checking if a username is taken, verifying an invitation code โ Zod's .refine() accepts an async function. React Hook Form calls the resolver asynchronously on submit. For live validation on blur, use mode: 'onBlur' in useForm options.
const schema = z.object({
username: z.string().min(3).refine(
async (val) => {
const { available } = await fetch(`/api/check-username?u=${val}`).then(r => r.json());
return available;
},
{ message: 'Username already taken' }
),
});
// mode: 'onBlur' triggers the async check when the user leaves the field
const form = useForm({ resolver: zodResolver(schema), mode: 'onBlur' });Multi-step forms: one instance, conditional steps
The cleanest multi-step form pattern is a single useForm instance with a step index controlling which fields are rendered. Don't split into separate form instances per step โ you lose cross-step validation and have to merge state manually. Validate only the current step's fields before advancing with form.trigger(['fieldA', 'fieldB']).
Patterns that save time
- Extract schema and defaults into a schema.ts file alongside the form component โ reusable in server actions and API route validation.
- Use z.infer<typeof schema> for the form type; never write a separate TypeScript interface that duplicates the schema.
- For edit forms, run the API response through z.parse() before passing to defaultValues โ normalises the shape and catches drift early.
- Use formState.isDirty to gate the submit button โ prevents unnecessary API calls when nothing changed.
Written by Appesto Engineering.