EmpireUI
Get Pro
← Blog8 min read#react hook form#zod#forms

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.

Developer typing React form code on a modern laptop screen

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/resolvers

That'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

Do I need both React Hook Form and Zod, or can I use one without the other?

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.

Does React Hook Form work with TypeScript out of the box?

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.

What's the difference between validation modes: onChange, onBlur, and onSubmit?

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.

Can I use React Hook Form with UI libraries like shadcn/ui or Radix?

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.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

react-hook-form + Zod: Full Type-Safe Form Validation in 2026react-hook-form Guide 2026: Controller, Register, Watch PatternsCheckout Form in React: Address, Payment, Review Steps with ValidationAddress Form in React: Country Select, Postcode Validation, Autofill