Skip to content
FrontHeaven
Light mode
Intermediate 25 min read

Forms at Scale: React Hook Form & Zod

Building high-performance, validated forms at scale using React Hook Form, Zod schema validation, and multi-step workflows.

React progress0%

Forms at Scale: React Hook Form & Zod

While basic controlled inputs work well for simple contact forms, complex enterprise forms with dozens of fields, interdependent validation rules, dynamic arrays, and multi-step wizards quickly degrade performance when using standard React state. Every keystroke triggers a re-render of the entire form.

React Hook Form solves this problem by using uncontrolled form inputs and native DOM subscriptions to eliminate unnecessary re-renders. Paired with Zod for schema validation, it provides a fast, type-safe form management experience.

Setting Up React Hook Form & Zod

Install the required packages:

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

Defining Type-Safe Schemas with Zod

Zod allows you to define declarative validation rules and automatically infer TypeScript types:

TypeScript
import { z } from 'zod';

export const registrationSchema = z.object({
  username: z.string().min(3, 'Username must be at least 3 characters').max(20),
  email: z.string().email('Please enter a valid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
    .regex(/[0-9]/, 'Password must contain at least one number'),
  confirmPassword: z.string(),
  acceptTerms: z.literal(true, {
    errorMap: () => ({ message: 'You must accept the terms and conditions' }),
  }),
}).refine(data => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'],
});

// Infer TypeScript type directly from schema:
export type RegistrationFormData = z.infer<typeof registrationSchema>;

Building the Form Component

Connect the Zod schema to React Hook Form using zodResolver:

tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registrationSchema, RegistrationFormData } from './schema';

export function RegistrationForm() {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<RegistrationFormData>({
    resolver: zodResolver(registrationSchema),
    mode: 'onBlur', // Validate when user leaves input field
  });

  const onSubmit = async (data: RegistrationFormData) => {
    console.log('Valid submission data:', data);
    await submitToServer(data);
    reset();
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
      <div>
        <label className="block text-sm font-semibold">Username</label>
        <input
          {...register('username')}
          className="w-full rounded-lg border p-2 text-sm"
        />
        {errors.username && (
          <p className="text-xs text-red-500 mt-1">{errors.username.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-semibold">Email</label>
        <input
          type="email"
          {...register('email')}
          className="w-full rounded-lg border p-2 text-sm"
        />
        {errors.email && (
          <p className="text-xs text-red-500 mt-1">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-semibold">Password</label>
        <input
          type="password"
          {...register('password')}
          className="w-full rounded-lg border p-2 text-sm"
        />
        {errors.password && (
          <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-semibold">Confirm Password</label>
        <input
          type="password"
          {...register('confirmPassword')}
          className="w-full rounded-lg border p-2 text-sm"
        />
        {errors.confirmPassword && (
          <p className="text-xs text-red-500 mt-1">{errors.confirmPassword.message}</p>
        )}
      </div>

      <div className="flex items-center gap-2">
        <input type="checkbox" {...register('acceptTerms')} id="terms" />
        <label htmlFor="terms" className="text-xs">I accept the Terms & Conditions</label>
      </div>
      {errors.acceptTerms && (
        <p className="text-xs text-red-500">{errors.acceptTerms.message}</p>
      )}

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full rounded-lg bg-primary py-2 text-white font-bold disabled:opacity-50"
      >
        {isSubmitting ? 'Registering...' : 'Create Account'}
      </button>
    </form>
  );
}

Handling Dynamic Field Arrays with useFieldArray

For forms where users can dynamically add or remove rows (e.g. invoice items, multiple phone numbers, skill tags), useFieldArray provides built-in array operations:

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

function InvoiceForm() {
  const { register, control, handleSubmit } = useForm();
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'lineItems',
  });

  return (
    <div>
      {fields.map((field, index) => (
        <div key={field.id} className="flex gap-2 mb-2">
          <input {...register(`lineItems.${index}.description`)} placeholder="Item description" />
          <input {...register(`lineItems.${index}.price`)} type="number" placeholder="Price" />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ description: '', price: 0 })}>
        + Add Item
      </button>
    </div>
  );
}

Best Practices

  • Validate on Blur for Optimal UX: Set mode: 'onBlur' so validation errors display only after the user finishes typing in a field.
  • Infer Types Directly from Zod: Avoid duplicating TypeScript interfaces by using z.infer<typeof schema>.
  • Use Controller for Custom UI Components: When integrating headless select menus or date pickers (shadcn/ui), wrap them with React Hook Form's <Controller />.

Summary

React Hook Form and Zod represent the gold standard for form handling in modern React. By combining uncontrolled inputs for zero-render overhead with Zod for strict schema validation, you build responsive, fully type-safe enterprise forms.

Finished studying? Lock it in.

Mark this lesson as completed to track your journey.