Skip to main content

Overview

Forms are a critical part of most applications. This guide covers form handling patterns, validation strategies, error messages, and submission workflows.

Form Libraries Comparison

Performant, flexible, and easy to use. Best choice for most applications.
npm install react-hook-form
  • Minimal re-renders
  • Small bundle size
  • Great TypeScript support
  • Easy integration with UI libraries

Getting Started with React Hook Form

1

Install Dependencies

npm install react-hook-form zod @hookform/resolvers
We’ll use Zod for schema validation, which provides excellent TypeScript support.
2

Create Basic Form

LoginForm.tsx
import { useForm } from 'react-hook-form';

interface LoginFormData {
  email: string;
  password: string;
}

export function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<LoginFormData>();
  
  const onSubmit = (data: LoginFormData) => {
    console.log(data);
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">Email</label>
        <input
          type="email"
          {...register('email', { required: 'Email is required' })}
          className="mt-1 block w-full rounded border-gray-300"
        />
        {errors.email && (
          <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
        )}
      </div>
      
      <div>
        <label className="block text-sm font-medium">Password</label>
        <input
          type="password"
          {...register('password', { required: 'Password is required' })}
          className="mt-1 block w-full rounded border-gray-300"
        />
        {errors.password && (
          <p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
        )}
      </div>
      
      <button
        type="submit"
        className="w-full px-4 py-2 bg-blue-600 text-white rounded"
      >
        Sign In
      </button>
    </form>
  );
}
3

Add Schema Validation

LoginFormWithValidation.tsx
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 LoginFormData = z.infer<typeof loginSchema>;

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
  });
  
  const onSubmit = async (data: LoginFormData) => {
    await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Form fields */}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Signing in...' : 'Sign In'}
      </button>
    </form>
  );
}
Using Zod with React Hook Form provides type-safe validation and automatic TypeScript inference for your form data.

Complete Form Example

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

const profileSchema = z.object({
  firstName: z.string().min(1, 'First name is required'),
  lastName: z.string().min(1, 'Last name is required'),
  email: z.string().email('Invalid email address'),
  phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional(),
  bio: z.string().max(500, 'Bio must be 500 characters or less').optional(),
  role: z.enum(['user', 'admin', 'moderator']),
  notifications: z.object({
    email: z.boolean(),
    sms: z.boolean(),
  }),
});

type ProfileFormData = z.infer<typeof profileSchema>;

interface UserProfileFormProps {
  initialData?: Partial<ProfileFormData>;
  onSubmit: (data: ProfileFormData) => Promise<void>;
}

export function UserProfileForm({ initialData, onSubmit }: UserProfileFormProps) {
  const {
    register,
    handleSubmit,
    control,
    formState: { errors, isSubmitting, isDirty },
    reset,
  } = useForm<ProfileFormData>({
    resolver: zodResolver(profileSchema),
    defaultValues: initialData || {
      notifications: { email: true, sms: false },
    },
  });
  
  const handleFormSubmit = async (data: ProfileFormData) => {
    try {
      await onSubmit(data);
      reset(data); // Reset form with new values
    } catch (error) {
      console.error('Failed to save profile:', error);
    }
  };
  
  return (
    <form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
      <div className="grid grid-cols-2 gap-4">
        <div>
          <label className="block text-sm font-medium text-gray-700">
            First Name
          </label>
          <input
            {...register('firstName')}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
          />
          {errors.firstName && (
            <p className="mt-1 text-sm text-red-600">{errors.firstName.message}</p>
          )}
        </div>
        
        <div>
          <label className="block text-sm font-medium text-gray-700">
            Last Name
          </label>
          <input
            {...register('lastName')}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
          />
          {errors.lastName && (
            <p className="mt-1 text-sm text-red-600">{errors.lastName.message}</p>
          )}
        </div>
      </div>
      
      <div>
        <label className="block text-sm font-medium text-gray-700">Email</label>
        <input
          type="email"
          {...register('email')}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
        {errors.email && (
          <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
        )}
      </div>
      
      <div>
        <label className="block text-sm font-medium text-gray-700">Phone</label>
        <input
          type="tel"
          {...register('phone')}
          placeholder="+1234567890"
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
        {errors.phone && (
          <p className="mt-1 text-sm text-red-600">{errors.phone.message}</p>
        )}
      </div>
      
      <div>
        <label className="block text-sm font-medium text-gray-700">Bio</label>
        <textarea
          {...register('bio')}
          rows={4}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
        {errors.bio && (
          <p className="mt-1 text-sm text-red-600">{errors.bio.message}</p>
        )}
      </div>
      
      <div>
        <label className="block text-sm font-medium text-gray-700">Role</label>
        <select
          {...register('role')}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        >
          <option value="user">User</option>
          <option value="moderator">Moderator</option>
          <option value="admin">Admin</option>
        </select>
        {errors.role && (
          <p className="mt-1 text-sm text-red-600">{errors.role.message}</p>
        )}
      </div>
      
      <div className="space-y-2">
        <label className="block text-sm font-medium text-gray-700">
          Notification Preferences
        </label>
        <div className="space-y-2">
          <label className="flex items-center">
            <input
              type="checkbox"
              {...register('notifications.email')}
              className="rounded border-gray-300"
            />
            <span className="ml-2 text-sm">Email notifications</span>
          </label>
          <label className="flex items-center">
            <input
              type="checkbox"
              {...register('notifications.sms')}
              className="rounded border-gray-300"
            />
            <span className="ml-2 text-sm">SMS notifications</span>
          </label>
        </div>
      </div>
      
      <div className="flex justify-end space-x-3">
        <button
          type="button"
          onClick={() => reset()}
          disabled={!isDirty || isSubmitting}
          className="px-4 py-2 border border-gray-300 rounded-md text-gray-700"
        >
          Reset
        </button>
        <button
          type="submit"
          disabled={isSubmitting || !isDirty}
          className="px-4 py-2 bg-blue-600 text-white rounded-md disabled:opacity-50"
        >
          {isSubmitting ? 'Saving...' : 'Save Changes'}
        </button>
      </div>
    </form>
  );
}

Advanced Validation

Cross-Field Validation

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

const passwordSchema = z.object({
  currentPassword: z.string().min(1, 'Current password is required'),
  newPassword: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain an uppercase letter')
    .regex(/[a-z]/, 'Password must contain a lowercase letter')
    .regex(/[0-9]/, 'Password must contain a number'),
  confirmPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

type PasswordFormData = z.infer<typeof passwordSchema>;

export function PasswordChangeForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
    setError,
  } = useForm<PasswordFormData>({
    resolver: zodResolver(passwordSchema),
  });
  
  const onSubmit = async (data: PasswordFormData) => {
    try {
      await fetch('/api/change-password', {
        method: 'POST',
        body: JSON.stringify(data),
      });
    } catch (error) {
      setError('currentPassword', {
        message: 'Current password is incorrect',
      });
    }
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label>Current Password</label>
        <input type="password" {...register('currentPassword')} />
        {errors.currentPassword && (
          <p className="text-red-600">{errors.currentPassword.message}</p>
        )}
      </div>
      
      <div>
        <label>New Password</label>
        <input type="password" {...register('newPassword')} />
        {errors.newPassword && (
          <p className="text-red-600">{errors.newPassword.message}</p>
        )}
      </div>
      
      <div>
        <label>Confirm Password</label>
        <input type="password" {...register('confirmPassword')} />
        {errors.confirmPassword && (
          <p className="text-red-600">{errors.confirmPassword.message}</p>
        )}
      </div>
      
      <button type="submit">Change Password</button>
    </form>
  );
}

Async Validation

UsernameField.tsx
import { useForm } from 'react-hook-form';
import { z } from 'zod';

const checkUsernameAvailable = async (username: string) => {
  const response = await fetch(`/api/check-username?username=${username}`);
  const { available } = await response.json();
  return available;
};

const signupSchema = z.object({
  username: z.string()
    .min(3, 'Username must be at least 3 characters')
    .regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
  email: z.string().email(),
});

export function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(signupSchema),
  });
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('username', {
          validate: async (value) => {
            const available = await checkUsernameAvailable(value);
            return available || 'Username is already taken';
          },
        })}
      />
      {errors.username && <p>{errors.username.message}</p>}
    </form>
  );
}
Async validation can impact performance. Consider debouncing validation or validating on blur instead of on every keystroke.

File Upload Forms

FileUploadForm.tsx
import { useForm } from 'react-hook-form';
import { useState } from 'react';

interface FileUploadFormData {
  title: string;
  description: string;
  files: FileList;
}

export function FileUploadForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FileUploadFormData>();
  const [uploadProgress, setUploadProgress] = useState(0);
  
  const onSubmit = async (data: FileUploadFormData) => {
    const formData = new FormData();
    formData.append('title', data.title);
    formData.append('description', data.description);
    
    Array.from(data.files).forEach((file) => {
      formData.append('files', file);
    });
    
    const xhr = new XMLHttpRequest();
    
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        setUploadProgress((e.loaded / e.total) * 100);
      }
    });
    
    xhr.addEventListener('load', () => {
      if (xhr.status === 200) {
        console.log('Upload successful');
      }
    });
    
    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label>Title</label>
        <input {...register('title', { required: true })} />
      </div>
      
      <div>
        <label>Description</label>
        <textarea {...register('description')} />
      </div>
      
      <div>
        <label>Files</label>
        <input
          type="file"
          multiple
          accept="image/*,.pdf"
          {...register('files', {
            required: 'Please select at least one file',
            validate: {
              fileSize: (files) => {
                const totalSize = Array.from(files).reduce(
                  (sum, file) => sum + file.size,
                  0
                );
                return totalSize <= 10 * 1024 * 1024 || 'Total file size must be less than 10MB';
              },
            },
          })}
        />
        {errors.files && <p className="text-red-600">{errors.files.message}</p>}
      </div>
      
      {uploadProgress > 0 && uploadProgress < 100 && (
        <div className="w-full bg-gray-200 rounded">
          <div
            className="bg-blue-600 h-2 rounded"
            style={{ width: `${uploadProgress}%` }}
          />
        </div>
      )}
      
      <button type="submit">Upload</button>
    </form>
  );
}

Dynamic Form Fields

DynamicFieldsForm.tsx
import { useForm, useFieldArray } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';

const socialLinkSchema = z.object({
  platform: z.string().min(1, 'Platform is required'),
  url: z.string().url('Invalid URL'),
});

const profileSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  socialLinks: z.array(socialLinkSchema),
});

type ProfileFormData = z.infer<typeof profileSchema>;

export function DynamicFieldsForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<ProfileFormData>({
    resolver: zodResolver(profileSchema),
    defaultValues: {
      socialLinks: [{ platform: '', url: '' }],
    },
  });
  
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'socialLinks',
  });
  
  return (
    <form onSubmit={handleSubmit(console.log)} className="space-y-4">
      <div>
        <label>Name</label>
        <input {...register('name')} />
        {errors.name && <p className="text-red-600">{errors.name.message}</p>}
      </div>
      
      <div>
        <label className="block font-medium mb-2">Social Links</label>
        {fields.map((field, index) => (
          <div key={field.id} className="flex gap-2 mb-2">
            <input
              {...register(`socialLinks.${index}.platform`)}
              placeholder="Platform (e.g., Twitter)"
              className="flex-1"
            />
            <input
              {...register(`socialLinks.${index}.url`)}
              placeholder="URL"
              className="flex-1"
            />
            <button
              type="button"
              onClick={() => remove(index)}
              className="px-3 py-1 bg-red-600 text-white rounded"
            >
              Remove
            </button>
          </div>
        ))}
        <button
          type="button"
          onClick={() => append({ platform: '', url: '' })}
          className="mt-2 px-4 py-2 bg-gray-200 rounded"
        >
          Add Social Link
        </button>
      </div>
      
      <button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded">
        Submit
      </button>
    </form>
  );
}
When using dynamic fields, ensure each field has a unique key prop to prevent React rendering issues.

Form Error Handling

FormWithErrorHandling.tsx
import { useForm } from 'react-hook-form';
import { useState } from 'react';

interface ApiError {
  field?: string;
  message: string;
}

export function FormWithErrorHandling() {
  const { register, handleSubmit, setError, formState: { errors } } = useForm();
  const [generalError, setGeneralError] = useState<string | null>(null);
  
  const onSubmit = async (data: any) => {
    try {
      setGeneralError(null);
      const response = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(data),
      });
      
      if (!response.ok) {
        const errors: ApiError[] = await response.json();
        
        errors.forEach((error) => {
          if (error.field) {
            setError(error.field as any, { message: error.message });
          } else {
            setGeneralError(error.message);
          }
        });
        return;
      }
      
      // Success
    } catch (error) {
      setGeneralError('An unexpected error occurred. Please try again.');
    }
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {generalError && (
        <div className="p-4 mb-4 bg-red-50 border border-red-200 rounded">
          <p className="text-red-800">{generalError}</p>
        </div>
      )}
      
      {/* Form fields */}
    </form>
  );
}

Best Practices

  • Always validate on both client and server
  • Provide clear, actionable error messages
  • Show field-level errors near the input
  • Disable submit button during submission
  • Reset form after successful submission
  • Use proper input types (email, tel, number)
  • Implement autofocus for better UX
  • Add loading states for async operations
  • Consider accessibility (labels, ARIA attributes)

Next Steps

Build docs developers (and LLMs) love