React Hook Form
Learn how to build performant, flexible, and extensible forms with React Hook Form, using easy-to-use validation and minimal re-renders.
Introduction to React Hook Form
React Hook Form is a performant, flexible, and extensible form library that leverages React hooks and uncontrolled components to provide excellent performance with minimal code. It's designed to reduce the amount of code you need to write while maintaining full control over your forms.
Why React Hook Form?
Key Benefits
- Performance: Minimizes re-renders by using uncontrolled components
- Small Bundle Size: Only ~8.6kB gzipped with zero dependencies
- Easy to Adopt: Simple API that's easy to learn and use
- Flexible Validation: Supports HTML standard validation and schema validation
- Great DX: Excellent TypeScript support and DevTools
- UI Library Friendly: Easy integration with any UI component library
Performance Comparison
React Hook Form isolates component re-renders, meaning only the components that need to update will re-render, unlike traditional controlled component approaches where the entire form re-renders on every keystroke.
// Traditional Controlled Form (Re-renders on every keystroke)
function ControlledForm() {
const [data, setData] = useState({ name: '', email: '' });
// Entire form re-renders when any field changes
}
// React Hook Form (Minimal re-renders)
function UncontrolledForm() {
const { register } = useForm();
// Only necessary parts re-render
}
Installation
Install React Hook Form using yarn:
yarn add react-hook-form
For TypeScript projects, types are included by default.
Basic Usage
Simple Form Example
import { useForm, SubmitHandler } from 'react-hook-form';
interface FormData {
email: string;
password: string;
}
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<FormData>();
const onSubmit: SubmitHandler<FormData> = async (data) => {
console.log(data);
// Handle form submission
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address'
}
})}
/>
{errors.email && (
<span className="error">{errors.email.message}</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters'
}
})}
/>
{errors.password && (
<span className="error">{errors.password.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Login'}
</button>
</form>
);
}
Core Concepts
1. Registration
The register function connects input fields to React Hook Form:
const { register } = useForm();
// Register an input
<input {...register('fieldName')} />
// Register with validation rules
<input
{...register('fieldName', {
required: 'This field is required',
minLength: { value: 3, message: 'Min length is 3' },
maxLength: { value: 20, message: 'Max length is 20' },
pattern: { value: /^[A-Za-z]+$/i, message: 'Letters only' }
})}
/>
2. Validation Rules
React Hook Form supports various built-in validation rules:
<input
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'Too short' },
maxLength: { value: 20, message: 'Too long' },
pattern: {
value: /^[a-zA-Z0-9_]+$/,
message: 'Only letters, numbers, and underscores'
},
validate: {
noSpaces: (value) =>
!/\s/.test(value) || 'No spaces allowed',
startsWithLetter: (value) =>
/^[a-zA-Z]/.test(value) || 'Must start with a letter'
}
})}
/>
3. Form State
Access various form states through formState:
const {
formState: {
errors, // Validation errors
isDirty, // Form has been modified
isValid, // Form is valid
isSubmitting, // Form is being submitted
isSubmitted, // Form has been submitted
touchedFields, // Fields that have been touched
dirtyFields // Fields that have been modified
}
} = useForm();
4. Handling Submission
const onSubmit: SubmitHandler<FormData> = async (data) => {
try {
await api.submitForm(data);
// Handle success
} catch (error) {
// Handle error
}
};
<form onSubmit={handleSubmit(onSubmit)}>
{/* form fields */}
</form>
Advanced Features
Watch Field Values
Monitor field values in real-time:
const { watch } = useForm();
// Watch all fields
const watchAllFields = watch();
// Watch specific field
const watchEmail = watch('email');
// Watch multiple fields
const watchFields = watch(['email', 'password']);
// Use in component
const email = watch('email');
return <p>Current email: {email}</p>;
Set Values Programmatically
const { setValue, reset } = useForm();
// Set a single value
setValue('email', 'user@example.com');
// Set multiple values
setValue('email', 'user@example.com', {
shouldValidate: true, // Trigger validation
shouldDirty: true, // Mark field as dirty
shouldTouch: true // Mark field as touched
});
// Reset form to default values
reset();
// Reset to specific values
reset({ email: '', password: '' });
Custom Validation
Implement complex validation logic:
const { register } = useForm();
<input
{...register('password', {
validate: {
hasUpperCase: (value) =>
/[A-Z]/.test(value) || 'Must contain uppercase letter',
hasLowerCase: (value) =>
/[a-z]/.test(value) || 'Must contain lowercase letter',
hasNumber: (value) =>
/[0-9]/.test(value) || 'Must contain a number',
hasSpecialChar: (value) =>
/[!@#$%^&*]/.test(value) || 'Must contain special character'
}
})}
/>
Async Validation
Validate against external services:
<input
{...register('username', {
validate: async (value) => {
const response = await fetch(`/api/check-username/${value}`);
const isAvailable = await response.json();
return isAvailable || 'Username already taken';
}
})}
/>
Dependent Fields
Validate fields based on other field values:
const { register, watch } = useForm();
const password = watch('password');
<input
{...register('confirmPassword', {
validate: (value) =>
value === password || 'Passwords do not match'
})}
/>
Integration with UI Libraries
With Custom Components
Use the Controller component for controlled components:
import { Controller, useForm } from 'react-hook-form';
import Select from 'react-select';
function MyForm() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="country"
control={control}
rules={{ required: 'Country is required' }}
render={({ field, fieldState: { error } }) => (
<div>
<Select
{...field}
options={countries}
placeholder="Select country"
/>
{error && <span>{error.message}</span>}
</div>
)}
/>
</form>
);
}
With Material-UI
import { Controller } from 'react-hook-form';
import { TextField } from '@mui/material';
<Controller
name="email"
control={control}
rules={{ required: 'Email is required' }}
render={({ field, fieldState: { error } }) => (
<TextField
{...field}
label="Email"
error={!!error}
helperText={error?.message}
/>
)}
/>
With shadcn/ui
import { Controller } from 'react-hook-form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
<Controller
name="username"
control={control}
render={({ field, fieldState: { error } }) => (
<div>
<Label htmlFor="username">Username</Label>
<Input
{...field}
id="username"
placeholder="Enter username"
/>
{error && <p className="text-sm text-red-500">{error.message}</p>}
</div>
)}
/>
Schema Validation
With Zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
age: z.number().min(18, 'Must be 18 or older')
});
type FormData = z.infer<typeof schema>;
function MyForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<input {...register('age', { valueAsNumber: true })} type="number" />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
With Yup
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
age: yup.number().positive().integer().min(18).required()
});
const { register, handleSubmit } = useForm({
resolver: yupResolver(schema)
});
Mode Options
Control when validation occurs:
useForm({
mode: 'onSubmit', // Validate on submit (default)
mode: 'onBlur', // Validate when field loses focus
mode: 'onChange', // Validate on every change
mode: 'onTouched', // Validate after field is touched
mode: 'all', // Validate on both blur and change
reValidateMode: 'onChange' // Re-validate on change after first submit
});
Field Arrays
Handle dynamic form fields:
import { useForm, useFieldArray } from 'react-hook-form';
function DynamicForm() {
const { register, control } = useForm({
defaultValues: {
items: [{ name: '', quantity: 0 }]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items'
});
return (
<form>
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`items.${index}.name` as const)}
placeholder="Item name"
/>
<input
{...register(`items.${index}.quantity` as const, {
valueAsNumber: true
})}
type="number"
placeholder="Quantity"
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: '', quantity: 0 })}
>
Add Item
</button>
</form>
);
}
Error Handling
Display Errors
// Single error
{errors.email && <span>{errors.email.message}</span>}
// Multiple errors for one field
{errors.password?.types?.required && <span>Required</span>}
{errors.password?.types?.minLength && <span>Too short</span>}
// All errors
{Object.keys(errors).length > 0 && (
<div className="error-summary">
<h4>Please fix the following errors:</h4>
<ul>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>{error.message}</li>
))}
</ul>
</div>
)}
Set Errors Manually
const { setError } = useForm();
// Set error for specific field
setError('email', {
type: 'manual',
message: 'Email already exists'
});
// Set multiple errors
setError('root.serverError', {
type: 'server',
message: 'Server error occurred'
});
DevTools
Install and use React Hook Form DevTools for debugging:
yarn add -D @hookform/devtools
import { useForm } from 'react-hook-form';
import { DevTool } from '@hookform/devtools';
function MyForm() {
const { register, control } = useForm();
return (
<>
<form>{/* form fields */}</form>
<DevTool control={control} />
</>
);
}
Best Practices
1. Use TypeScript
interface FormData {
email: string;
password: string;
rememberMe: boolean;
}
const { register } = useForm<FormData>();
2. Provide Clear Error Messages
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Please enter a valid email address'
}
})}
/>
3. Use Schema Validation for Complex Forms
For forms with many fields or complex validation logic, use Zod or Yup.
4. Handle Loading States
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
5. Reset Form After Submission
const onSubmit = async (data) => {
await submitForm(data);
reset(); // Reset form to default values
};
Complete Example
Here's a comprehensive registration form example:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const registrationSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[0-9]/, 'Must contain number'),
confirmPassword: z.string(),
age: z.number().min(18, 'Must be 18 or older'),
terms: z.boolean().refine((val) => val === true, {
message: 'You must accept the terms and conditions'
})
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword']
});
type RegistrationData = z.infer<typeof registrationSchema>;
export default function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
reset
} = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
mode: 'onBlur'
});
const onSubmit = async (data: RegistrationData) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
alert('Registration successful!');
reset();
} else {
throw new Error('Registration failed');
}
} catch (error) {
console.error('Error:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md mx-auto p-6">
<h2 className="text-2xl font-bold mb-6">Register</h2>
<div className="mb-4">
<label htmlFor="username" className="block mb-2">Username</label>
<input
id="username"
{...register('username')}
className="w-full p-2 border rounded"
/>
{errors.username && (
<p className="text-red-500 text-sm mt-1">{errors.username.message}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="email" className="block mb-2">Email</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full p-2 border rounded"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="password" className="block mb-2">Password</label>
<input
id="password"
type="password"
{...register('password')}
className="w-full p-2 border rounded"
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="confirmPassword" className="block mb-2">Confirm Password</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
className="w-full p-2 border rounded"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword.message}</p>
)}
</div>
<div className="mb-4">
<label htmlFor="age" className="block mb-2">Age</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })}
className="w-full p-2 border rounded"
/>
{errors.age && (
<p className="text-red-500 text-sm mt-1">{errors.age.message}</p>
)}
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
{...register('terms')}
className="mr-2"
/>
<span>I accept the terms and conditions</span>
</label>
{errors.terms && (
<p className="text-red-500 text-sm mt-1">{errors.terms.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting || !isDirty}
className="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600 disabled:bg-gray-400"
>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
);
}