React Hook Form + Zod: The Form Stack That Finally Makes Sense
React Hook Form and Zod together cut form boilerplate in half and give you runtime-safe validation without the usual pain. Here's how to wire them up properly.
Why Most Form Libraries Still Feel Wrong
Forms are the part of the UI that developers dread. Not because they're conceptually hard — they're not — but because every library you've tried has made you write three times more code than you expected. Formik was fine in 2019. It isn't anymore.
React Hook Form came along and made a compelling argument: stop re-rendering on every keystroke, use uncontrolled inputs by default, and let the browser do what it's already good at. The result is a library that's genuinely fast, ships at around 9kb gzipped, and doesn't make you feel like you're fighting it.
Honestly, the real unlock isn't the library itself — it's pairing it with Zod for schema validation. That combination gives you a single source of truth for your form shape, TypeScript inference for free, and error messages that aren't just 'required'. When these two tools click together, form code starts to feel almost boring. That's the goal.
Setting Up the Stack
Install both packages. You'll also need the official resolver adapter, which is what connects Zod's schema to React Hook Form's validation pipeline.
npm install react-hook-form zod @hookform/resolversThat's it. No wrapping your entire app in a provider, no peer dependency maze. Worth noting: @hookform/resolvers version 3.x dropped support for Zod versions below 3.20, so make sure you're not on an old lock file.
Once you have the packages, the basic wiring looks like this — define your Zod schema, infer the TypeScript type from it, pass it through zodResolver, and hand the resolver to useForm. Everything else flows from there.
A Working Login Form From Scratch
Let's skip the contrived 'name field only' example and build something you'd actually ship. Here's a login form with email validation, a password minimum length of 8 characters, and proper TypeScript types — all in one file.
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
type LoginFormValues = z.infer<typeof loginSchema>
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
})
const onSubmit = async (data: LoginFormValues) => {
// data is fully typed — no casting needed
await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(data),
})
}
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<input
{...register('email')}
type="email"
placeholder="Email"
style={{ padding: '10px 14px', borderRadius: 8, border: '1px solid #ccc', width: '100%' }}
/>
{errors.email && <p style={{ color: '#ef4444', fontSize: 13, marginTop: 4 }}>{errors.email.message}</p>}
</div>
<div>
<input
{...register('password')}
type="password"
placeholder="Password"
style={{ padding: '10px 14px', borderRadius: 8, border: '1px solid #ccc', width: '100%' }}
/>
{errors.password && <p style={{ color: '#ef4444', fontSize: 13, marginTop: 4 }}>{errors.password.message}</p>}
</div>
<button
type="submit"
disabled={isSubmitting}
style={{ padding: '12px 24px', borderRadius: 8, background: '#6366f1', color: '#fff', border: 'none', cursor: 'pointer' }}
>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
)
}Notice you never touched useState. No [value, setValue] pairs, no manual onChange handlers, no e.target.value. The form manages its own state internally, and you get the final validated object only when the user submits. That's the mental model shift that makes everything cleaner.
The z.infer<typeof loginSchema> line is doing real work here. Your onSubmit callback receives data that TypeScript knows is { email: string; password: string } — not any, not unknown. If you change the schema later, your handler will break at compile time, not at runtime in production.
Handling Complex Validation with Zod Refinements
Simple string validations are easy. The interesting cases are things like 'password confirmation must match' or 'at least one of these two fields is required'. Zod's .refine() and .superRefine() methods handle these without any extra library.
const signupSchema = z
.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'], // attach the error to the right field
})The path option is the part people miss. Without it, the error lands on the root of the object and React Hook Form won't know which field to attach it to. You'd see errors.root instead of errors.confirmPassword. Always specify the path for cross-field rules.
In practice, you can chain as many .refine() calls as you want. They run in sequence, and they stop at the first failure by default. If you need all of them to run simultaneously — say, you're validating a multi-step form section — switch to .superRefine() and call ctx.addIssue() manually instead of returning false.
Server-Side Validation and the useFormState Pattern
Client-side validation catches typos. Server-side validation catches everything else — duplicate emails, expired tokens, permission errors. You need both, and they should feel like one system to the user.
React Hook Form 7.x introduced setError with an 'root' key specifically for this. Call it after your API returns an error and the error surfaces just like any other form error — no separate error state, no useState<string | null>(null) noise.
const onSubmit = async (data: LoginFormValues) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(data),
})
if (!res.ok) {
const { message } = await res.json()
setError('root', { message })
return
}
// handle success
}
// In your JSX:
{errors.root && <p style={{ color: '#ef4444' }}>{errors.root.message}</p>}One more thing — if you're on Next.js 14+ and using Server Actions, you'd reach for useFormState from react-dom instead, and pipe the action's return value into setError inside a useEffect. The pattern is slightly different but the Zod schema stays identical. Your validation logic is portable.
Performance: Why RHF's Uncontrolled Approach Matters
How many re-renders does your current form trigger per keystroke? If you're using controlled inputs with useState, the answer is one per character. On a 20-field form, that adds up fast — especially if those fields live inside a component tree that includes context consumers or expensive children.
React Hook Form defaults to uncontrolled mode. Inputs register directly with the form via refs. You only get a re-render when validation state changes (on blur or submit, depending on your mode config). Set mode: 'onChange' if you want live validation, but know you're opting back into per-keystroke renders for those fields.
Quick aside: if you need a controlled value — say, for a custom <Select> component that doesn't accept a ref — use the Controller component from React Hook Form. It wraps any third-party input and bridges the gap without you losing the validation integration.
For styling form inputs with modern aesthetics, you might want to pair your forms with components from Empire UI. The glassmorphism components in particular look great as form containers — frosted panels with 24px blur radius hit different for auth flows.
Common Mistakes and How to Avoid Them
The most common mistake is defining your Zod schema inside the component. Don't. Every render creates a new schema object, which means a new resolver reference, which can cause subtle re-render loops. Define schemas at module level, outside of any component.
Second mistake: not reading formState correctly. React Hook Form uses a Proxy to track which parts of formState you actually read, so it can avoid unnecessary re-renders. If you destructure { errors } but never access isSubmitting, you won't re-render when the submit state changes. This is a feature, not a bug — but it surprises people.
Look, the third one trips up everyone at some point: async default values. If you're loading defaults from an API, you can't just pass the initial fetch result because it'll be undefined on the first render. Use reset() inside a useEffect once your data loads, or pass a Promise to the defaultValues option — that's been supported since RHF v7.34.
Want to see a polished example of these forms in a real UI context? Browse the templates on Empire UI — several of them ship with form sections you can pull apart and adapt.
FAQ
You can use React Hook Form with its built-in validation rules (required, minLength, pattern, etc.) and skip Zod entirely. But Zod gives you TypeScript inference and reusable schemas you can share with your API layer — worth it on any serious project.
Yes, and it's well-typed. Pass your form value type as a generic to useForm<YourType>() and everything downstream infers correctly. Pairing it with z.infer<typeof yourSchema> means you define the shape once and TypeScript tracks it everywhere.
onSubmit (the default) only validates when the user submits — fewest re-renders, best for simple forms. onBlur validates when a field loses focus. onChange validates live on every keystroke, which gives the best UX feedback but costs more renders.
Yes, via the Controller component. Wrap any third-party input that doesn't forward a ref and you get full RHF integration. Most shadcn/ui examples already show this pattern with their Form primitives built on top of RHF.