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
- React Hook Form
- Formik
- Controlled Components
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
Mature library with extensive features and community support.
npm install formik
- Declarative API
- Built-in validation
- Field-level validation
- Large ecosystem
Native React approach without dependencies.
- Full control
- No dependencies
- More boilerplate
- Manual optimization needed
Getting Started with React Hook Form
Install Dependencies
npm install react-hook-form zod @hookform/resolvers
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>
);
}
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
- Integrate with Data Fetching for form submissions
- Secure forms with Authentication
- Deploy with Production Setup