React Hook Form
React Hook Form is the go-to library for forms in React. It's performant, has minimal re-renders, and integrates well with validation libraries.
Why React Hook Form?
| Feature | React Hook Form | Formik | Native |
|---|---|---|---|
| Re-renders | Minimal | Every change | Every change |
| Bundle size | ~9kb | ~13kb | 0kb |
| Validation | Built-in + Zod | Yup | Manual |
| TypeScript | Excellent | Good | Manual |
Installation
npm install react-hook-formBasic Usage
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
password: string;
}
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>();
const onSubmit = async (data: FormData) => {
await loginUser(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register('email', { required: 'Email is required' })}
/>
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register('password', { required: 'Password is required' })}
/>
{errors.password && <span>{errors.password.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}Register Options
// Required field
{...register('name', { required: 'Name is required' })}
// Min/max length
{...register('username', {
minLength: { value: 3, message: 'Min 3 characters' },
maxLength: { value: 20, message: 'Max 20 characters' },
})}
// Pattern matching
{...register('email', {
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
// Custom validation
{...register('username', {
validate: (value) =>
!value.includes(' ') || 'Username cannot contain spaces',
})}
// Async validation
{...register('username', {
validate: async (value) => {
const exists = await checkUsernameExists(value);
return !exists || 'Username already taken';
},
})}Form State
const {
formState: {
errors, // Validation errors
isSubmitting, // Form is being submitted
isValid, // All fields are valid
isDirty, // Any field has been modified
dirtyFields, // Which fields have been modified
touchedFields, // Which fields have been touched
},
} = useForm<FormData>();Default Values
// Static defaults
const { register } = useForm<FormData>({
defaultValues: {
email: '',
role: 'user',
},
});
// Async defaults (for edit forms)
const { register } = useForm<FormData>({
defaultValues: async () => {
const user = await fetchUser(userId);
return {
email: user.email,
name: user.name,
};
},
});Reset Form
const { reset } = useForm<FormData>();
// Reset to default values
reset();
// Reset to specific values
reset({
email: 'new@example.com',
name: 'New Name',
});
// Reset after successful submit
const onSubmit = async (data: FormData) => {
await saveData(data);
reset(); // Clear form
};Watch Values
const { watch } = useForm<FormData>();
// Watch single field
const email = watch('email');
// Watch multiple fields
const [email, password] = watch(['email', 'password']);
// Watch all fields
const allValues = watch();
// Conditional rendering based on value
function Form() {
const accountType = watch('accountType');
return (
<form>
<select {...register('accountType')}>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
{accountType === 'business' && (
<input {...register('companyName')} placeholder="Company Name" />
)}
</form>
);
}Set Values Programmatically
const { setValue, getValues } = useForm<FormData>();
// Set single value
setValue('email', 'user@example.com');
// Set with validation
setValue('email', 'user@example.com', {
shouldValidate: true,
shouldDirty: true,
});
// Get current values
const email = getValues('email');
const allValues = getValues();Error Handling
const { setError, clearErrors } = useForm<FormData>();
// Set error manually (e.g., from API response)
const onSubmit = async (data: FormData) => {
try {
await saveData(data);
} catch (error) {
if (error.field === 'email') {
setError('email', {
type: 'server',
message: 'Email already exists',
});
} else {
setError('root', {
type: 'server',
message: 'Something went wrong',
});
}
}
};
// Display root error
{errors.root && <div className="error">{errors.root.message}</div>}
// Clear specific error
clearErrors('email');
// Clear all errors
clearErrors();Form with Loading State
function EditUserForm({ userId }: { userId: string }) {
const { register, handleSubmit, reset, formState } = useForm<FormData>();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function loadUser() {
const user = await fetchUser(userId);
reset(user); // Populate form with fetched data
setIsLoading(false);
}
loadUser();
}, [userId, reset]);
if (isLoading) return <FormSkeleton />;
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
);
}TypeScript Integration
// Define your form types
interface SignUpForm {
email: string;
password: string;
confirmPassword: string;
acceptTerms: boolean;
}
// Use with useForm
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignUpForm>({
defaultValues: {
email: '',
password: '',
confirmPassword: '',
acceptTerms: false,
},
});
// Errors are fully typed
errors.email?.message; // string | undefined
errors.acceptTerms?.message; // string | undefinedBest Practices
- Always use TypeScript - Define form interfaces for type safety
- Set default values - Prevents undefined values
- Use mode wisely -
onBlurfor better UX,onChangefor real-time validation - Handle loading states - Show skeletons while loading async defaults
- Use Zod for validation - See the Zod Validation page