Skip to main content
{/}codefunc
GuidesJuly 7, 2026 · 8 min read

React Hook Form + Zod: Modern Form Validation

A practical guide to building forms with React Hook Form and Zod — type-safe validation, error handling, complex form patterns, and integration with UI libraries.

By codefunc

reactformsvalidationzodtypescriptreact-hook-form

Forms are everywhere in web apps, and they are notoriously hard to get right. React Hook Form handles form state and submission with minimal re-renders. Zod provides runtime validation with TypeScript inference. Together, they create type-safe forms where your validation schema and TypeScript types stay in sync.

Bottom line: Use React Hook Form for form state management — it is fast and has excellent DX. Use Zod for validation schemas — they generate TypeScript types automatically. Connect them with @hookform/resolvers. This pattern handles everything from simple login forms to complex multi-step wizards with proper typing throughout.

Quick setup

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

Basic form

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

// 1. Define schema
const schema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

// 2. Infer type from schema
type FormData = z.infer<typeof schema>;

// 3. Use in component
function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = async (data: FormData) => {
    // data is fully typed
    console.log(data.email, data.password);
  };

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

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <p className="error">{errors.password.message}</p>}
      </div>

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

Zod schema patterns

Strings

const schema = z.object({
  // Required string
  name: z.string().min(1, 'Name is required'),
  
  // Email
  email: z.string().email('Invalid email'),
  
  // URL
  website: z.string().url('Invalid URL'),
  
  // Regex pattern
  username: z.string().regex(/^[a-z0-9_]+$/, 'Lowercase letters, numbers, and underscores only'),
  
  // Length constraints
  bio: z.string().max(500, 'Bio must be 500 characters or less'),
  
  // Optional
  nickname: z.string().optional(),
  
  // Optional with default
  role: z.string().default('user'),
  
  // Trim whitespace
  title: z.string().trim().min(1, 'Title is required'),
});

Numbers

const schema = z.object({
  age: z.number().min(18, 'Must be 18 or older').max(120),
  price: z.number().positive('Price must be positive'),
  quantity: z.number().int('Must be a whole number').min(1),
  
  // From string input (HTML inputs are strings)
  amount: z.coerce.number().positive(),
});

Dates

const schema = z.object({
  // Date object
  birthDate: z.date(),
  
  // From string input
  startDate: z.coerce.date(),
  
  // With constraints
  eventDate: z.date().min(new Date(), 'Date must be in the future'),
});

Enums and unions

const schema = z.object({
  // Enum
  status: z.enum(['draft', 'published', 'archived']),
  
  // Union
  contactMethod: z.union([
    z.literal('email'),
    z.literal('phone'),
    z.literal('mail'),
  ]),
  
  // Discriminated union
  notification: z.discriminatedUnion('type', [
    z.object({ type: z.literal('email'), address: z.string().email() }),
    z.object({ type: z.literal('sms'), phone: z.string() }),
  ]),
});

Arrays and objects

const schema = z.object({
  // Array of strings
  tags: z.array(z.string()).min(1, 'At least one tag required'),
  
  // Array of objects
  addresses: z.array(z.object({
    street: z.string(),
    city: z.string(),
    zip: z.string(),
  })).max(3, 'Maximum 3 addresses'),
  
  // Nested object
  profile: z.object({
    firstName: z.string(),
    lastName: z.string(),
  }),
});

Conditional validation

const schema = z.object({
  hasCompany: z.boolean(),
  companyName: z.string().optional(),
}).refine(
  (data) => !data.hasCompany || data.companyName,
  {
    message: 'Company name is required when has company is checked',
    path: ['companyName'],
  }
);

// Or using superRefine for multiple conditions
const schema = z.object({
  password: z.string(),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Passwords do not match',
      path: ['confirmPassword'],
    });
  }
});

React Hook Form patterns

Form state

const {
  register,        // Connect inputs
  handleSubmit,    // Wrap submit handler
  formState: {
    errors,        // Validation errors
    isSubmitting,  // Submit in progress
    isValid,       // All fields valid
    isDirty,       // Any field changed
    dirtyFields,   // Which fields changed
    touchedFields, // Which fields touched
  },
  watch,           // Watch field values
  setValue,        // Set field value programmatically
  reset,           // Reset form
  setError,        // Set error manually
  clearErrors,     // Clear errors
  trigger,         // Trigger validation
} = useForm<FormData>({
  resolver: zodResolver(schema),
  defaultValues: {
    email: '',
    password: '',
  },
  mode: 'onBlur', // Validate on blur
});

Validation modes

useForm({
  mode: 'onSubmit',    // Validate on submit (default)
  mode: 'onBlur',      // Validate on blur
  mode: 'onChange',    // Validate on change
  mode: 'onTouched',   // Validate on blur, then on change
  mode: 'all',         // Validate on blur and change
});

Watching values

function Form() {
  const { register, watch } = useForm<FormData>();
  
  // Watch single field
  const email = watch('email');
  
  // Watch multiple fields
  const [firstName, lastName] = watch(['firstName', 'lastName']);
  
  // Watch all fields
  const allValues = watch();
  
  // Watch with callback (for side effects)
  useEffect(() => {
    const subscription = watch((value, { name, type }) => {
      console.log(name, value);
    });
    return () => subscription.unsubscribe();
  }, [watch]);

  return <form>...</form>;
}

Default values

// Static defaults
useForm({
  defaultValues: {
    email: '',
    role: 'user',
  },
});

// Async defaults (loading existing data)
useForm({
  defaultValues: async () => {
    const user = await fetchUser();
    return {
      email: user.email,
      name: user.name,
    };
  },
});

// Reset with new values
const { reset } = useForm();
useEffect(() => {
  if (userData) {
    reset(userData);
  }
}, [userData, reset]);

Server-side errors

const { setError } = useForm<FormData>();

const onSubmit = async (data: FormData) => {
  try {
    await api.register(data);
  } catch (error) {
    if (error.code === 'EMAIL_EXISTS') {
      setError('email', {
        type: 'server',
        message: 'This email is already registered',
      });
    } else {
      setError('root.serverError', {
        message: 'Something went wrong. Please try again.',
      });
    }
  }
};

// Display root error
{errors.root?.serverError && (
  <div className="alert">{errors.root.serverError.message}</div>
)}

Dynamic fields (useFieldArray)

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

const schema = z.object({
  users: z.array(z.object({
    name: z.string().min(1, 'Name is required'),
    email: z.string().email('Invalid email'),
  })).min(1, 'At least one user required'),
});

type FormData = z.infer<typeof schema>;

function UsersForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: {
      users: [{ name: '', email: '' }],
    },
  });

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`users.${index}.name`)} placeholder="Name" />
          {errors.users?.[index]?.name && (
            <p>{errors.users[index].name.message}</p>
          )}
          
          <input {...register(`users.${index}.email`)} placeholder="Email" />
          {errors.users?.[index]?.email && (
            <p>{errors.users[index].email.message}</p>
          )}
          
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}
      
      <button type="button" onClick={() => append({ name: '', email: '' })}>
        Add User
      </button>
      
      <button type="submit">Submit</button>
    </form>
  );
}

Controlled components

For custom components or UI libraries:

import { Controller } from 'react-hook-form';

function Form() {
  const { control, handleSubmit } = useForm<FormData>();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="category"
        control={control}
        render={({ field, fieldState: { error } }) => (
          <>
            <Select
              value={field.value}
              onChange={field.onChange}
              onBlur={field.onBlur}
              options={categoryOptions}
            />
            {error && <p className="error">{error.message}</p>}
          </>
        )}
      />
    </form>
  );
}

With UI libraries (shadcn/ui)

import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';

function LoginForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="ada@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
      </form>
    </Form>
  );
}

Multi-step forms

const stepSchemas = [
  z.object({
    firstName: z.string().min(1),
    lastName: z.string().min(1),
  }),
  z.object({
    email: z.string().email(),
    phone: z.string().optional(),
  }),
  z.object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  }),
];

function MultiStepForm() {
  const [step, setStep] = useState(0);
  const [formData, setFormData] = useState({});
  
  const currentSchema = stepSchemas[step];
  
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(currentSchema),
    defaultValues: formData,
  });

  const onNext = (data) => {
    setFormData((prev) => ({ ...prev, ...data }));
    if (step < stepSchemas.length - 1) {
      setStep((s) => s + 1);
    } else {
      // Final submit
      submitForm({ ...formData, ...data });
    }
  };

  const onBack = () => setStep((s) => s - 1);

  return (
    <form onSubmit={handleSubmit(onNext)}>
      {step === 0 && (
        <>
          <input {...register('firstName')} />
          <input {...register('lastName')} />
        </>
      )}
      {step === 1 && (
        <>
          <input {...register('email')} />
          <input {...register('phone')} />
        </>
      )}
      {step === 2 && (
        <>
          <input type="password" {...register('password')} />
          <input type="password" {...register('confirmPassword')} />
        </>
      )}
      
      {step > 0 && <button type="button" onClick={onBack}>Back</button>}
      <button type="submit">{step === 2 ? 'Submit' : 'Next'}</button>
    </form>
  );
}

File uploads

const schema = z.object({
  avatar: z
    .instanceof(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'].includes(files[0]?.type),
      'Only JPEG and PNG are allowed'
    ),
});

function AvatarUpload() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  });

  const onSubmit = async (data) => {
    const formData = new FormData();
    formData.append('avatar', data.avatar[0]);
    await uploadAvatar(formData);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="file" accept="image/*" {...register('avatar')} />
      {errors.avatar && <p>{errors.avatar.message}</p>}
      <button type="submit">Upload</button>
    </form>
  );
}

Testing forms

Use the Fake Data Generator to create test data. Validate your schema logic with JSON Validator. Test regex patterns with Regex Tester.

// Testing with React Testing Library
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('shows validation errors', async () => {
  render(<LoginForm />);
  
  await userEvent.click(screen.getByRole('button', { name: /log in/i }));
  
  await waitFor(() => {
    expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
    expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
  });
});

test('submits valid form', async () => {
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);
  
  await userEvent.type(screen.getByLabelText(/email/i), 'ada@example.com');
  await userEvent.type(screen.getByLabelText(/password/i), 'password123');
  await userEvent.click(screen.getByRole('button', { name: /log in/i }));
  
  await waitFor(() => {
    expect(onSubmit).toHaveBeenCalledWith({
      email: 'ada@example.com',
      password: 'password123',
    });
  });
});

Practical takeaway

React Hook Form + Zod is the modern standard for form handling in React. Define your schema once, get TypeScript types and validation for free. Use register for native inputs, Controller for custom components. Handle async validation with refine. Use useFieldArray for dynamic lists. Keep schemas in separate files for reuse. Test validation logic separately from UI.

Sources

Try it on codefunc

Related articles

3

Search tools

Find a developer tool