React

React Hook Form + Zod Complete Guide (2026)

Master form validation in React with React Hook Form v7 and Zod. Covers useForm, zodResolver, useFieldArray, Server Actions, and shadcn/ui integration.

May 14, 202611 min read
Share:
React Hook Form + Zod Complete Guide (2026)

Forms are where most React apps get messy fast. You start with a useState for each field, then add validation logic, then error messages, then async submission — and suddenly you have 200 lines of spaghetti for a login form.

React Hook Form solves the performance problem. Zod solves the type-safe validation problem. Together they're the standard stack for forms in 2026.

Why React Hook Form

React Hook Form uses uncontrolled inputs by default. Unlike Formik or controlled forms, it doesn't re-render the whole component on every keystroke. In a form with 20 fields, that's a significant difference.

The API is also much simpler:

// Controlled (the old way) — re-renders on every keystroke
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [errors, setErrors] = useState({})
 
// React Hook Form — no re-renders until submit/blur
const { register, handleSubmit, formState: { errors } } = useForm()

Installation

npm install react-hook-form zod @hookform/resolvers

@hookform/resolvers is the bridge between React Hook Form and Zod (also supports Yup, Joi, and others).

Basic form with Zod schema

Define your schema first — it's the single source of truth for both validation and TypeScript types:

import { z } from 'zod'
 
const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})
 
type LoginForm = z.infer<typeof loginSchema>

Then wire it to React Hook Form:

'use client'
 
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
 
const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})
 
type LoginForm = z.infer<typeof loginSchema>
 
export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema),
  })
 
  const onSubmit = async (data: LoginForm) => {
    try {
      await signIn(data)
    } catch (err) {
      // Set server-side errors back on the form
      setError('email', { message: 'Invalid credentials' })
    }
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          {...register('email')}
          type="email"
          placeholder="Email"
        />
        {errors.email && <p>{errors.email.message}</p>}
      </div>
 
      <div>
        <input
          {...register('password')}
          type="password"
          placeholder="Password"
        />
        {errors.password && <p>{errors.password.message}</p>}
      </div>
 
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  )
}

register attaches the input to the form. handleSubmit validates before calling your submit function. errors gives you field-level messages from Zod.

Complex Zod schemas

Optional fields with defaults

const profileSchema = z.object({
  name: z.string().min(2).max(50),
  bio: z.string().max(200).optional(),
  website: z.string().url('Must be a valid URL').optional().or(z.literal('')),
  role: z.enum(['admin', 'user', 'viewer']).default('user'),
  age: z.coerce.number().int().min(18).optional(),  // coerce converts string inputs
})

Cross-field validation with .refine()

const registerSchema = 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'],  // which field gets the error
})

Nested objects

const checkoutSchema = z.object({
  customer: z.object({
    firstName: z.string().min(1, 'Required'),
    lastName: z.string().min(1, 'Required'),
    email: z.string().email(),
  }),
  shipping: z.object({
    address: z.string().min(5),
    city: z.string().min(2),
    country: z.string().length(2),
    zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
  }),
})

Access nested errors with dot notation:

{errors.customer?.email && <p>{errors.customer.email.message}</p>}
{errors.shipping?.zip && <p>{errors.shipping.zip.message}</p>}

Dynamic arrays with useFieldArray

For lists where users can add/remove items:

'use client'
 
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
 
const teamSchema = z.object({
  members: z.array(z.object({
    name: z.string().min(1, 'Name required'),
    email: z.string().email('Invalid email'),
    role: z.enum(['owner', 'member', 'viewer']),
  })).min(1, 'Add at least one member'),
})
 
type TeamForm = z.infer<typeof teamSchema>
 
export function TeamForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<TeamForm>({
    resolver: zodResolver(teamSchema),
    defaultValues: {
      members: [{ name: '', email: '', role: 'member' }],
    },
  })
 
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'members',
  })
 
  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`members.${index}.name`)} placeholder="Name" />
          {errors.members?.[index]?.name && (
            <p>{errors.members[index].name.message}</p>
          )}
 
          <input {...register(`members.${index}.email`)} placeholder="Email" />
 
          <select {...register(`members.${index}.role`)}>
            <option value="member">Member</option>
            <option value="owner">Owner</option>
            <option value="viewer">Viewer</option>
          </select>
 
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
 
      <button
        type="button"
        onClick={() => append({ name: '', email: '', role: 'member' })}
      >
        Add member
      </button>
 
      <button type="submit">Save team</button>
    </form>
  )
}

Integration with shadcn/ui

shadcn/ui components are controlled, so you use Controller instead of register:

'use client'
 
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
 
export function ProfileForm() {
  const form = useForm<ProfileForm>({
    resolver: zodResolver(profileSchema),
    defaultValues: { name: '', role: 'user' },
  })
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Name</FormLabel>
              <FormControl>
                <Input placeholder="Your name" {...field} />
              </FormControl>
              <FormMessage />  {/* auto-renders Zod error */}
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="role"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Role</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="admin">Admin</SelectItem>
                  <SelectItem value="user">User</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <Button type="submit">Save</Button>
      </form>
    </Form>
  )
}

The Form component from shadcn/ui wraps FormProvider, and FormMessage automatically reads from formState.errors. Zero manual error wiring.

Integration with Server Actions

For Next.js Server Actions, you can validate with Zod server-side and return errors back to the form:

// app/actions/profile.ts
'use server'
 
import { z } from 'zod'
 
const profileSchema = z.object({
  name: z.string().min(2).max(50),
  bio: z.string().max(200).optional(),
})
 
export type ProfileActionState = {
  errors?: { name?: string[]; bio?: string[] }
  success?: boolean
}
 
export async function updateProfile(
  _prev: ProfileActionState,
  formData: FormData
): Promise<ProfileActionState> {
  const result = profileSchema.safeParse({
    name: formData.get('name'),
    bio: formData.get('bio'),
  })
 
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors }
  }
 
  await db.users.update(result.data)
  return { success: true }
}
'use client'
 
import { useActionState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { updateProfile } from '@/app/actions/profile'
 
export function ProfileForm() {
  const [state, action] = useActionState(updateProfile, {})
 
  const { register, formState: { errors } } = useForm<ProfileForm>({
    resolver: zodResolver(profileSchema),
  })
 
  return (
    <form action={action}>
      <input {...register('name')} name="name" />
      {/* Client-side Zod errors */}
      {errors.name && <p>{errors.name.message}</p>}
      {/* Server-side errors */}
      {state.errors?.name && <p>{state.errors.name[0]}</p>}
 
      <button type="submit">Save</button>
    </form>
  )
}

Useful form utilities

watch — react to field changes

const { watch } = useForm<Schema>()
 
// Watch a single field
const country = watch('country')
 
// Conditionally render based on value
{country === 'US' && <input {...register('state')} placeholder="State" />}
 
// Watch all fields (use sparingly — triggers re-renders)
const allValues = watch()

setValue — update a field programmatically

const { setValue } = useForm<Schema>()
 
// After an async operation, set the value
const handleLocationSelect = (coords: Coords) => {
  setValue('latitude', coords.lat)
  setValue('longitude', coords.lng)
  setValue('address', coords.address, { shouldValidate: true })
}

reset — clear the form

const { reset } = useForm<Schema>()
 
// Reset to empty
reset()
 
// Reset with specific values (useful after loading data from API)
reset({ name: user.name, bio: user.bio })

trigger — validate a specific field on demand

const { trigger } = useForm<Schema>()
 
// Validate email before moving to next step
const handleNext = async () => {
  const isValid = await trigger('email')
  if (isValid) setStep(2)
}

File uploads

const uploadSchema = z.object({
  title: z.string().min(1),
  file: z
    .custom<FileList>()
    .refine((files) => files?.length > 0, 'File is required')
    .refine((files) => files?.[0]?.size <= 5_000_000, 'Max file size is 5MB')
    .refine(
      (files) => ['image/jpeg', 'image/png', 'image/webp'].includes(files?.[0]?.type),
      'Only JPEG, PNG, and WebP are allowed'
    ),
})
<input
  type="file"
  accept="image/*"
  {...register('file')}
/>
{errors.file && <p>{errors.file.message}</p>}

Multi-step form pattern

For multi-step forms, validate only the current step's fields before advancing:

'use client'
 
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
 
const step1Schema = z.object({ email: z.string().email(), name: z.string().min(2) })
const step2Schema = z.object({ password: z.string().min(8), confirmPassword: z.string() })
  .refine((d) => d.password === d.confirmPassword, {
    message: "Passwords don't match", path: ['confirmPassword']
  })
 
const fullSchema = step1Schema.merge(step2Schema.omit({ confirmPassword: true }))
 
export function MultiStepForm() {
  const [step, setStep] = useState(1)
 
  const form = useForm<z.infer<typeof fullSchema>>({
    resolver: zodResolver(fullSchema),
    mode: 'onBlur',
  })
 
  const handleNext = async () => {
    const fields = step === 1 ? ['email', 'name'] : ['password']
    const valid = await form.trigger(fields as any)
    if (valid) setStep((s) => s + 1)
  }
 
  return (
    <form onSubmit={form.handleSubmit(console.log)}>
      {step === 1 && (
        <>
          <input {...form.register('email')} placeholder="Email" />
          <input {...form.register('name')} placeholder="Name" />
          <button type="button" onClick={handleNext}>Next</button>
        </>
      )}
      {step === 2 && (
        <>
          <input {...form.register('password')} type="password" placeholder="Password" />
          <button type="submit">Create account</button>
        </>
      )}
    </form>
  )
}

Validation modes

React Hook Form validates on submit by default. You can change it:

useForm({
  resolver: zodResolver(schema),
  mode: 'onBlur',       // validate when field loses focus
  mode: 'onChange',     // validate on every keystroke (expensive)
  mode: 'onSubmit',     // default — only on submit
  mode: 'onTouched',    // validate on first blur, then onChange
  mode: 'all',          // all of the above
})

For most forms, 'onBlur' is the best UX — errors appear after the user leaves a field, not while they're still typing.

Common mistakes

Mistake 1: Using value instead of defaultValues

// Wrong — React Hook Form is uncontrolled, setting value doesn't work
useForm({ defaultValues: { email: user?.email ?? '' } }) // ✅
 
// After async data loads, use reset():
useEffect(() => {
  if (user) reset({ email: user.email })
}, [user])

Mistake 2: Missing key in useFieldArray

Always use field.id as the key, not the array index:

{fields.map((field, i) => (
  <div key={field.id}> {/* ✅ not key={i} */}

Mistake 3: Forgetting name attribute for Server Actions

When using action={serverAction}, the input needs a name attribute alongside register:

<input {...register('email')} name="email" />

This guide pairs perfectly with the tRPC complete guide — use React Hook Form for client-side validation, tRPC mutations for type-safe submission, and Zod schemas shared between client and server.

For UI components, the shadcn/ui guide covers the Form component primitives in detail. And for server-side form handling, check out React Server Actions for the useActionState integration.

#react#react-hook-form#zod#typescript#forms
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.