Back to the blog

React Hook Form + Zod: Forms and Validation

Building forms with React Hook Form and Zod validation: schema, error handling, controlled fields and best practices.

January 5, 2026
10 min read
1033 views
MOLOTILO

MOLOTILO DIGITAL

React Hook Form + Zod: Forms and Validation

Why React Hook Form

React Hook Form is a library for working with forms that minimizes re-renders and simplifies validation. Combined with Zod it gives a powerful type-safe form system.

A basic form

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// Zod schema
const loginSchema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z.string().min(8, 'At least 8 characters'),
  rememberMe: z.boolean().optional()
});

type LoginFormData = z.infer<typeof loginSchema>;

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
      rememberMe: false
    }
  });

  const onSubmit = async (data: LoginFormData) => {
    console.log(data);
    // API request
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          {...register('email')}
          type="email"
          placeholder="Email"
        />
        {errors.email && (
          <span className="error">{errors.email.message}</span>
        )}
      </div>

      <div>
        <input
          {...register('password')}
          type="password"
          placeholder="Password"
        />
        {errors.password && (
          <span className="error">{errors.password.message}</span>
        )}
      </div>

      <label>
        <input {...register('rememberMe')} type="checkbox" />
        Remember me
      </label>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  );
}

Complex Zod schemas

// Registration with password confirmation
const registerSchema = z.object({
  name: z.string().min(2, 'At least 2 characters'),
  email: z.string().email('Invalid email'),
  password: z
    .string()
    .min(8, 'At least 8 characters')
    .regex(/[A-Z]/, 'An uppercase letter is required')
    .regex(/[0-9]/, 'A number is required'),
  confirmPassword: z.string(),
  age: z.number().min(18, 'At least 18 years old').optional(),
  role: z.enum(['user', 'admin']),
  terms: z.literal(true, {
    errorMap: () => ({ message: 'Accept the terms' })
  })
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword']
});

// Conditional validation
const orderSchema = z.object({
  deliveryType: z.enum(['pickup', 'delivery']),
  address: z.string().optional()
}).refine((data) => {
  if (data.deliveryType === 'delivery') {
    return data.address && data.address.length > 5;
  }
  return true;
}, {
  message: 'Enter a delivery address',
  path: ['address']
});

// Arrays and nested objects
const productSchema = z.object({
  name: z.string().min(1),
  variants: z.array(z.object({
    size: z.string(),
    price: z.number().positive(),
    stock: z.number().int().min(0)
  })).min(1, 'Add at least one variant')
});

Controlled fields

import { useForm, Controller } from 'react-hook-form';
import { Select, DatePicker, Slider } from './ui';

function ProductForm() {
  const { control, handleSubmit } = useForm<ProductFormData>({
    resolver: zodResolver(productSchema)
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Custom Select */}
      <Controller
        name="category"
        control={control}
        render={({ field, fieldState }) => (
          <Select
            {...field}
            options={categories}
            error={fieldState.error?.message}
          />
        )}
      />

      {/* DatePicker */}
      <Controller
        name="publishDate"
        control={control}
        render={({ field }) => (
          <DatePicker
            selected={field.value}
            onChange={field.onChange}
          />
        )}
      />

      {/* Slider */}
      <Controller
        name="price"
        control={control}
        render={({ field }) => (
          <Slider
            value={field.value}
            onChange={field.onChange}
            min={0}
            max={10000}
          />
        )}
      />
    </form>
  );
}

Dynamic fields (useFieldArray)

import { useForm, useFieldArray } from 'react-hook-form';

function OrderForm() {
  const { register, control, handleSubmit } = useForm<OrderFormData>({
    defaultValues: {
      items: [{ name: '', quantity: 1 }]
    }
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'items'
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`items.${index}.name`)} />
          <input
            {...register(`items.${index}.quantity`, { valueAsNumber: true })}
            type="number"
          />
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}

      <button type="button" onClick={() => append({ name: '', quantity: 1 })}>
        Add item
      </button>

      <button type="submit">Place order</button>
    </form>
  );
}

Handling server errors

function RegistrationForm() {
  const { setError, handleSubmit } = useForm<FormData>({
    resolver: zodResolver(schema)
  });

  const onSubmit = async (data: FormData) => {
    try {
      await registerUser(data);
    } catch (error) {
      if (error.code === 'EMAIL_EXISTS') {
        setError('email', {
          type: 'server',
          message: 'Email is already registered'
        });
      } else {
        setError('root', {
          type: 'server',
          message: 'An error occurred, please try again later'
        });
      }
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {errors.root && <div className="error">{errors.root.message}</div>}
      {/* form fields */}
    </form>
  );
}

Conclusion

React Hook Form + Zod is a powerful combination for type-safe forms. Minimal re-renders, declarative validation, excellent TypeScript support. Use Controller for custom components and useFieldArray for dynamic fields.

Client-side validation is for UX, server-side validation is for security. Use both.

Enjoyed the article?

Subscribe to our blog so you don’t miss new posts