π£ Lesson 7.2: React Hook Form
In the previous lesson, you learned how to build complex forms from scratch with vanilla React. You discovered it takes a lot of code to handle validation, errors, performance, and dynamic fields. React Hook Form solves these problems with an elegant, performant API that reduces boilerplate dramatically. In this lesson, you'll learn how to use React Hook Form to build professional forms with minimal code while maintaining full type safety with TypeScript.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Understand why React Hook Form is better than vanilla form handling
- Install and configure React Hook Form in a TypeScript project
- Use the useForm hook to manage form state
- Register inputs with proper TypeScript typing
- Implement validation rules with built-in validators
- Handle form submission and errors effectively
- Type your forms properly with TypeScript
- Build performant forms that don't re-render unnecessarily
Estimated Time: 60-75 minutes
Project: Refactor a complex form to use React Hook Form
π In This Lesson
π€ Why React Hook Form?
In Lesson 7.1, you built forms the hard wayβmanaging state, validation, errors, and performance manually. While this taught you the fundamentals, it's a lot of work for every form. Let's compare vanilla React form handling with React Hook Form:
The Pain of Vanilla Forms
Remember all the code you wrote in the previous lesson?
// SO. MUCH. CODE. π©
function VanillaForm() {
// State for each field
const [formData, setFormData] = useState({ name: '', email: '', phone: '' });
// Separate error state
const [errors, setErrors] = useState<Record<string, string>>({});
// Separate touched state
const [touched, setTouched] = useState<Record<string, boolean>>({});
// Loading state
const [isSubmitting, setIsSubmitting] = useState(false);
// Change handlers
const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
// Blur handlers
const handleBlur = (field: string) => () => {
setTouched(prev => ({ ...prev, [field]: true }));
// Run validation...
};
// Validation functions
const validateName = (name: string) => {
if (!name) return 'Name is required';
if (name.length < 2) return 'Name must be at least 2 characters';
};
// More validation functions...
// Submit handler
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate everything...
// Set errors...
// Submit if valid...
};
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={handleChange('name')}
onBlur={handleBlur('name')}
/>
{errors.name && touched.name && <span>{errors.name}</span>}
{/* Repeat for every field... */}
</form>
);
}
β Problems with Vanilla Form Handling
- Too much boilerplate - 100+ lines for a simple form
- Manual state management - Track data, errors, touched, loading
- Performance issues - Every keystroke causes re-renders
- Repetitive code - Similar patterns for every field
- Complex validation - Manual validation logic for each field
- Error handling - Manually sync errors with fields
The React Hook Form Solution
Now let's see the same form with React Hook Form:
// SO. MUCH. CLEANER. π
import { useForm } from 'react-hook-form';
interface FormData {
name: string;
email: string;
phone: string;
}
function ReactHookFormExample() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log(data); // Already validated!
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('name', {
required: 'Name is required',
minLength: { value: 2, message: 'Name must be at least 2 characters' }
})}
/>
{errors.name && <span>{errors.name.message}</span>}
<input
{...register('email', {
required: 'Email is required',
pattern: { value: /^\S+@\S+$/i, message: 'Invalid email' }
})}
/>
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
β React Hook Form Benefits
- Minimal boilerplate - 90% less code for the same functionality
- Automatic state management - No manual useState needed
- Excellent performance - Uncontrolled inputs = no re-renders on every keystroke
- Built-in validation - Declarative validation rules
- TypeScript support - Full type safety out of the box
- Smaller bundle size - Only ~9KB minified + gzipped
- Easy integration - Works with UI libraries and validation schemas
How React Hook Form Achieves Performance
React Hook Form uses uncontrolled components under the hood. This means inputs manage their own state internally (like regular HTML inputs), and React Hook Form uses refs to read values only when needed (on blur, on change, or on submit).
π‘ Performance Comparison
| Approach | Re-renders on Keystroke | Form with 20 Fields |
|---|---|---|
| Vanilla React (Controlled) | Every field | 20 re-renders per keystroke |
| React Hook Form | None (until validation/submit) | 0 re-renders per keystroke π |
When to Use React Hook Form
β Perfect For:
- Forms with 5+ fields
- Forms with complex validation rules
- Dynamic forms with field arrays
- Performance-critical applications
- TypeScript projects (excellent type support)
- Forms that integrate with validation libraries (Zod, Yup)
β Maybe Overkill For:
- Single-field forms (search boxes)
- Forms with 1-2 fields and no validation
- Quick prototypes where setup time matters more than performance
π¦ Installation and Setup
Let's get React Hook Form installed and set up in your project.
Installation
Install React Hook Form using npm or yarn:
# Using npm
npm install react-hook-form
# Using yarn
yarn add react-hook-form
# Using pnpm
pnpm add react-hook-form
π‘ Version Information
As of this writing, React Hook Form v7 is the latest stable version. It's compatible with:
- React 16.8+ (any version with hooks)
- React Native
- TypeScript 4.0+
- All modern browsers
Importing React Hook Form
The main hook you'll use is useForm. Here's how to import it:
import { useForm } from 'react-hook-form';
// You can also import other utilities
import {
useForm, // Main hook
useFieldArray, // For dynamic fields
useWatch, // Watch specific fields
Controller, // For controlled components
FormProvider // For context
} from 'react-hook-form';
Basic Setup
Here's the simplest possible React Hook Form setup:
import { useForm } from 'react-hook-form';
function SimpleForm() {
// Initialize the form
const { register, handleSubmit } = useForm();
// Handle form submission
const onSubmit = (data: any) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('firstName')} />
<input {...register('lastName')} />
<button type="submit">Submit</button>
</form>
);
}
β What Just Happened?
useForm()initializes the form and returns utilitiesregister()connects each input to the formhandleSubmit()wraps your submit handler- When submitted,
onSubmitreceives all form values as an object
TypeScript Configuration
For TypeScript projects, define your form data interface and pass it to useForm:
import { useForm } from 'react-hook-form';
// Define your form data structure
interface LoginFormData {
email: string;
password: string;
rememberMe: boolean;
}
function LoginForm() {
// Pass the type to useForm
const { register, handleSubmit } = useForm<LoginFormData>();
// Now onSubmit has proper types!
const onSubmit = (data: LoginFormData) => {
console.log(data.email); // β
TypeScript knows this exists
console.log(data.password); // β
Fully typed
console.log(data.rememberMe); // β
boolean type
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="email" {...register('email')} />
<input type="password" {...register('password')} />
<input type="checkbox" {...register('rememberMe')} />
<button type="submit">Login</button>
</form>
);
}
π‘ TypeScript Benefits
- Autocomplete - IDE suggests field names when calling
register() - Type safety - Catches typos in field names at compile time
- Intellisense - Know what properties exist on your form data
- Refactoring - Change form structure safely with TypeScript's help
ποΈ Exercise 1: Install and Setup
Task: Set up React Hook Form in your project and create a simple contact form.
- Install React Hook Form:
npm install react-hook-form - Create a new component called
ContactForm.tsx - Define an interface for the form data with fields: name, email, message
- Use useForm with your interface
- Create a form with three inputs
- Log the form data when submitted
π‘ Hint
Start with the type definition, then call useForm<YourType>(). Use the spread operator with register on each input: {...register('fieldName')}.
β Solution
import { useForm } from 'react-hook-form';
interface ContactFormData {
name: string;
email: string;
message: string;
}
function ContactForm() {
const { register, handleSubmit } = useForm<ContactFormData>();
const onSubmit = (data: ContactFormData) => {
console.log('Form submitted:', data);
alert(`Thanks ${data.name}! We'll contact you at ${data.email}`);
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="name" style={{ display: 'block', marginBottom: '0.5rem' }}>
Name
</label>
<input
id="name"
{...register('name')}
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
Email
</label>
<input
id="email"
type="email"
{...register('email')}
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="message" style={{ display: 'block', marginBottom: '0.5rem' }}>
Message
</label>
<textarea
id="message"
{...register('message')}
rows={5}
style={{ width: '100%', padding: '0.5rem', fontFamily: 'inherit' }}
/>
</div>
<button
type="submit"
style={{
padding: '0.75rem 1.5rem',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Send Message
</button>
</form>
);
}
export default ContactForm;
π¬ Your First React Hook Form
Let's build a complete registration form step by step to understand how all the pieces work together.
Step 1: Define Your Form Data
Always start by defining the shape of your form data with TypeScript:
interface RegistrationFormData {
username: string;
email: string;
password: string;
confirmPassword: string;
age: number;
terms: boolean;
}
Step 2: Initialize useForm
Call useForm with your type and destructure the utilities you need:
import { useForm } from 'react-hook-form';
function RegistrationForm() {
const {
register, // Function to register inputs
handleSubmit, // Function to wrap your submit handler
formState, // Object containing form state
watch, // Function to watch field values
reset // Function to reset the form
} = useForm<RegistrationFormData>({
defaultValues: {
username: '',
email: '',
password: '',
confirmPassword: '',
age: 18,
terms: false
}
});
// Access errors from formState
const { errors, isSubmitting, isValid } = formState;
// Rest of component...
}
π‘ useForm Configuration
The useForm hook accepts a configuration object with many options:
defaultValues- Initial form valuesmode- When to validate ('onBlur', 'onChange', 'onSubmit', 'all')reValidateMode- When to revalidate after errorsresolver- External validation schema (for Zod, Yup, etc.)
Step 3: Register Your Inputs
Use the register function to connect inputs to the form:
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Spread the register result onto your input */}
<input
{...register('username')}
placeholder="Username"
/>
<input
type="email"
{...register('email')}
placeholder="Email"
/>
<input
type="password"
{...register('password')}
placeholder="Password"
/>
<input
type="number"
{...register('age', { valueAsNumber: true })}
placeholder="Age"
/>
<label>
<input
type="checkbox"
{...register('terms')}
/>
I agree to the terms
</label>
<button type="submit">Register</button>
</form>
);
β
What register Does
When you spread {...register('fieldName')} on an input, it adds:
nameattributerefto track the inputonChangehandleronBlurhandler
All of these work together to manage the field without you writing any code!
Step 4: Handle Submission
Create your submit handler and wrap it with handleSubmit:
const onSubmit = async (data: RegistrationFormData) => {
try {
// Data is already validated at this point!
console.log('Form data:', data);
// Make API call
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
alert('Registration successful!');
reset(); // Clear the form
}
} catch (error) {
console.error('Registration failed:', error);
}
};
π‘ Submit Handler Guarantees
Your onSubmit function only runs if:
- β All validation rules pass
- β No errors exist
- β The form is valid
If validation fails, onSubmit won't be called and errors will be shown automatically!
Complete Registration Form Example
Let's put it all together into a complete, working registration form:
import { useForm } from 'react-hook-form';
interface RegistrationFormData {
username: string;
email: string;
password: string;
confirmPassword: string;
age: number;
terms: boolean;
}
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
watch
} = useForm<RegistrationFormData>({
defaultValues: {
username: '',
email: '',
password: '',
confirmPassword: '',
age: 18,
terms: false
}
});
const onSubmit = async (data: RegistrationFormData) => {
console.log('Form submitted:', data);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Registration successful!');
reset(); // Clear form after success
};
// Watch password for confirm password validation
const password = watch('password');
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
<h2>Create Account</h2>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="username" style={{ display: 'block', marginBottom: '0.5rem' }}>
Username *
</label>
<input
id="username"
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'Username must be at least 3 characters' }
})}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.username ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{errors.username && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.username.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
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'
}
})}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{errors.email && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.email.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password" style={{ display: 'block', marginBottom: '0.5rem' }}>
Password *
</label>
<input
id="password"
type="password"
{...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'Password must be at least 8 characters' },
pattern: {
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: 'Password must contain uppercase, lowercase, and number'
}
})}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{errors.password && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.password.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="confirmPassword" style={{ display: 'block', marginBottom: '0.5rem' }}>
Confirm Password *
</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword', {
required: 'Please confirm your password',
validate: value => value === password || 'Passwords do not match'
})}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.confirmPassword ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{errors.confirmPassword && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.confirmPassword.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="age" style={{ display: 'block', marginBottom: '0.5rem' }}>
Age *
</label>
<input
id="age"
type="number"
{...register('age', {
required: 'Age is required',
valueAsNumber: true,
min: { value: 18, message: 'You must be at least 18 years old' },
max: { value: 120, message: 'Please enter a valid age' }
})}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.age ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{errors.age && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.age.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
{...register('terms', {
required: 'You must agree to the terms and conditions'
})}
/>
I agree to the terms and conditions *
</label>
{errors.terms && (
<span style={{ color: '#f56565', fontSize: '0.875rem', display: 'block', marginTop: '0.25rem' }}>
{errors.terms.message}
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
style={{
width: '100%',
padding: '0.75rem',
background: isSubmitting ? '#ddd' : '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '1rem',
fontWeight: 'bold',
cursor: isSubmitting ? 'not-allowed' : 'pointer'
}}
>
{isSubmitting ? 'Creating Account...' : 'Create Account'}
</button>
</form>
);
}
export default RegistrationForm;
β What This Example Demonstrates
- TypeScript typing - Full type safety for form data
- Validation rules - Required, minLength, pattern, custom validation
- Error display - Show errors next to fields
- Visual feedback - Red borders on invalid fields
- Cross-field validation - Password confirmation using
watch - Loading state - Disable button during submission
- Form reset - Clear form after successful submission
ποΈ Exercise 2: Build Your First Form
Task: Create a login form with React Hook Form.
Requirements:
- Fields: email, password, remember me (checkbox)
- Email validation (required + email format)
- Password validation (required + min 6 characters)
- Show error messages below each field
- Disable submit button while submitting
- Console.log the form data on submit
π‘ Hint
Start with the interface. Use register with validation options. Access errors from formState. Use isSubmitting to disable the button.
β Solution
import { useForm } from 'react-hook-form';
interface LoginFormData {
email: string;
password: string;
rememberMe: boolean;
}
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<LoginFormData>({
defaultValues: {
email: '',
password: '',
rememberMe: false
}
});
const onSubmit = async (data: LoginFormData) => {
console.log('Login data:', data);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
alert(`Welcome back! Remember me: ${data.rememberMe}`);
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '400px', margin: '2rem auto' }}>
<h2>Login</h2>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
Email
</label>
<input
id="email"
type="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Please enter a valid email'
}
})}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{errors.email && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.email.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password" style={{ display: 'block', marginBottom: '0.5rem' }}>
Password
</label>
<input
id="password"
type="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 6,
message: 'Password must be at least 6 characters'
}
})}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{errors.password && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.password.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" {...register('rememberMe')} />
Remember me
</label>
</div>
<button
type="submit"
disabled={isSubmitting}
style={{
width: '100%',
padding: '0.75rem',
background: isSubmitting ? '#ddd' : '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isSubmitting ? 'not-allowed' : 'pointer'
}}
>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
export default LoginForm;
π£ The useForm Hook
The useForm hook is the heart of React Hook Form. Let's explore everything it returns and how to use each piece.
useForm Return Values
When you call useForm(), it returns an object with many utilities:
const {
// Field registration
register, // Register inputs
unregister, // Unregister inputs
// Form submission
handleSubmit, // Wrap your submit handler
// Form state
formState, // Object with errors, isDirty, isValid, etc.
// Watching values
watch, // Watch field values
getValues, // Get current values
// Setting values
setValue, // Set a field value
reset, // Reset form
// Validation
trigger, // Manually trigger validation
clearErrors, // Clear specific errors
setError, // Manually set errors
// Form control
control // For Controller component
} = useForm<FormData>();
π‘ Most Commonly Used
You won't need all of these for every form. The most common are:
register- Connect inputs (used in 100% of forms)handleSubmit- Handle form submission (100%)formState- Access errors and form state (95%)watch- Watch field values for dependent logic (50%)reset- Clear form after submission (40%)setValue- Programmatically set values (30%)
formState Deep Dive
The formState object contains valuable information about your form's current state:
const { formState } = useForm<FormData>();
// Destructure what you need
const {
errors, // Object with all field errors
isDirty, // True if any field has been modified
dirtyFields, // Object showing which fields are dirty
touchedFields, // Object showing which fields have been touched
isSubmitted, // True if form has been submitted
isSubmitting, // True during submission
isValid, // True if form has no errors
isValidating, // True during validation
submitCount // Number of times form was submitted
} = formState;
Practical Examples
function FormWithState() {
const {
register,
handleSubmit,
formState: { errors, isDirty, isValid, isSubmitting }
} = useForm<{ email: string; password: string }>({
mode: 'onChange' // Validate on change
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true })} />
<input {...register('password', { required: true })} />
{/* Disable submit until form is valid and not already submitting */}
<button
type="submit"
disabled={!isValid || isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
{/* Show save status */}
{isDirty && (
<p style={{ color: 'orange' }}>You have unsaved changes</p>
)}
{/* Show error count */}
{Object.keys(errors).length > 0 && (
<p style={{ color: 'red' }}>
Please fix {Object.keys(errors).length} error(s)
</p>
)}
</form>
);
}
watch() - Watching Field Values
The watch function lets you observe field values, useful for dependent fields or conditional rendering:
function ConditionalForm() {
const { register, watch } = useForm<{
hasCompany: boolean;
companyName: string;
companySize: string;
}>();
// Watch a single field
const hasCompany = watch('hasCompany');
// Watch multiple fields
const [hasCompany2, companyName] = watch(['hasCompany', 'companyName']);
// Watch all fields
const allValues = watch();
return (
<form>
<label>
<input type="checkbox" {...register('hasCompany')} />
I have a company
</label>
{/* Conditionally show fields based on checkbox */}
{hasCompany && (
<>
<input
{...register('companyName', { required: 'Company name is required' })}
placeholder="Company Name"
/>
<select {...register('companySize')}>
<option value="">Select size</option>
<option value="1-10">1-10 employees</option>
<option value="11-50">11-50 employees</option>
<option value="50+">50+ employees</option>
</select>
</>
)}
</form>
);
}
β οΈ Performance Note
Calling watch() without arguments watches ALL fields, which can cause re-renders. For better performance:
- β
Watch specific fields:
watch('fieldName') - β
Watch subset:
watch(['field1', 'field2']) - β Avoid:
watch()(watches everything)
setValue() - Programmatically Setting Values
Sometimes you need to set form values programmatically (e.g., from API data):
function EditProfileForm() {
const { register, setValue, handleSubmit } = useForm<{
name: string;
email: string;
bio: string;
}>();
// Load user data when component mounts
useEffect(() => {
async function loadUserData() {
const userData = await fetchUserProfile();
// Set multiple values
setValue('name', userData.name);
setValue('email', userData.email);
setValue('bio', userData.bio);
// Or use reset to set all at once
// reset(userData);
}
loadUserData();
}, [setValue]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
<input {...register('email')} />
<textarea {...register('bio')} />
<button type="submit">Save</button>
</form>
);
}
reset() - Resetting the Form
Clear the form or reset it to specific values:
function FormWithReset() {
const { register, handleSubmit, reset } = useForm<{
title: string;
description: string;
}>({
defaultValues: {
title: '',
description: ''
}
});
const onSubmit = async (data: any) => {
await saveData(data);
// Reset to default values (empty)
reset();
// OR reset to specific values
reset({
title: '',
description: 'Default description'
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('title')} />
<textarea {...register('description')} />
<button type="submit">Submit</button>
<button type="button" onClick={() => reset()}>
Clear Form
</button>
</form>
);
}
β When to Use Each Method
| Method | Use Case | Example |
|---|---|---|
watch |
Observe field values | Show/hide fields based on checkbox |
getValues |
Get values without re-render | Access values in onClick handler |
setValue |
Set single field value | Update one field from API |
reset |
Set multiple values or clear form | Load form from API or clear after submit |
ποΈ Exercise 3: Using useForm Features
Task: Create a shipping form with conditional address fields.
Requirements:
- Checkbox: "Billing address same as shipping"
- If checked, hide billing address fields
- Use
watchto observe the checkbox - Show a message if form has unsaved changes (
isDirty) - Disable submit button if form is invalid
π‘ Hint
Use watch('sameAddress') to watch the checkbox. Access isDirty and isValid from formState. Conditionally render billing fields with {!sameAddress && ...}.
β Solution
import { useForm } from 'react-hook-form';
interface ShippingFormData {
shippingAddress: string;
shippingCity: string;
sameAddress: boolean;
billingAddress?: string;
billingCity?: string;
}
function ShippingForm() {
const {
register,
handleSubmit,
watch,
formState: { errors, isDirty, isValid }
} = useForm<ShippingFormData>({
mode: 'onChange',
defaultValues: {
shippingAddress: '',
shippingCity: '',
sameAddress: true,
billingAddress: '',
billingCity: ''
}
});
const sameAddress = watch('sameAddress');
const onSubmit = (data: ShippingFormData) => {
console.log('Shipping data:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
<h2>Shipping Information</h2>
{/* Unsaved changes warning */}
{isDirty && (
<div style={{
background: '#fff3cd',
padding: '0.75rem',
marginBottom: '1rem',
borderRadius: '4px'
}}>
β οΈ You have unsaved changes
</div>
)}
{/* Shipping Address */}
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="shippingAddress">Shipping Address *</label>
<input
id="shippingAddress"
{...register('shippingAddress', { required: 'Shipping address is required' })}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.5rem' }}
/>
{errors.shippingAddress && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.shippingAddress.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="shippingCity">City *</label>
<input
id="shippingCity"
{...register('shippingCity', { required: 'City is required' })}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.5rem' }}
/>
{errors.shippingCity && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.shippingCity.message}
</span>
)}
</div>
{/* Same address checkbox */}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" {...register('sameAddress')} />
Billing address same as shipping
</label>
</div>
{/* Conditional Billing Address */}
{!sameAddress && (
<>
<h3>Billing Address</h3>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="billingAddress">Billing Address *</label>
<input
id="billingAddress"
{...register('billingAddress', {
required: !sameAddress && 'Billing address is required'
})}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.5rem' }}
/>
{errors.billingAddress && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.billingAddress.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="billingCity">City *</label>
<input
id="billingCity"
{...register('billingCity', {
required: !sameAddress && 'City is required'
})}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.5rem' }}
/>
{errors.billingCity && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.billingCity.message}
</span>
)}
</div>
</>
)}
<button
type="submit"
disabled={!isValid}
style={{
width: '100%',
padding: '0.75rem',
background: isValid ? '#667eea' : '#ddd',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isValid ? 'pointer' : 'not-allowed'
}}
>
Submit Order
</button>
</form>
);
}
export default ShippingForm;
π Registering Inputs
The register function is how you connect HTML inputs to React Hook Form. Let's explore all the ways to use it.
Basic Registration
The simplest form of registration just needs the field name:
function BasicRegistration() {
const { register } = useForm<{ email: string }>();
return (
<input {...register('email')} />
);
}
When you spread register('email'), it adds these props to your input:
// What register returns:
{
name: 'email',
ref: (instance) => { /* stores ref to input */ },
onChange: (e) => { /* updates form state */ },
onBlur: (e) => { /* triggers validation */ }
}
Registration with Validation
Pass a second argument with validation rules:
function ValidationExample() {
const { register } = useForm<{
username: string;
email: string;
age: number;
}>();
return (
<form>
{/* Required field */}
<input
{...register('username', {
required: 'Username is required'
})}
/>
{/* Multiple validation rules */}
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Invalid email format'
},
minLength: {
value: 5,
message: 'Email must be at least 5 characters'
}
})}
/>
{/* Number validation */}
<input
type="number"
{...register('age', {
required: 'Age is required',
valueAsNumber: true, // Convert to number
min: {
value: 18,
message: 'Must be at least 18'
},
max: {
value: 120,
message: 'Please enter a valid age'
}
})}
/>
</form>
);
}
π‘ Built-in Validation Rules
| Rule | Type | Example |
|---|---|---|
required |
boolean | string | required: 'This is required' |
min |
number | object | min: { value: 0, message: '...' } |
max |
number | object | max: { value: 100, message: '...' } |
minLength |
number | object | minLength: { value: 3, message: '...' } |
maxLength |
number | object | maxLength: { value: 20, message: '...' } |
pattern |
RegExp | object | pattern: { value: /regex/, message: '...' } |
validate |
function | object | validate: value => value !== 'test' |
Different Input Types
React Hook Form works with all HTML input types:
function AllInputTypes() {
const { register } = useForm();
return (
<form>
{/* Text input */}
<input type="text" {...register('name')} />
{/* Email input */}
<input type="email" {...register('email')} />
{/* Number input */}
<input
type="number"
{...register('age', { valueAsNumber: true })}
/>
{/* Checkbox */}
<input type="checkbox" {...register('agree')} />
{/* Radio buttons */}
<input type="radio" value="male" {...register('gender')} />
<input type="radio" value="female" {...register('gender')} />
{/* Select dropdown */}
<select {...register('country')}>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
</select>
{/* Textarea */}
<textarea {...register('bio')} />
{/* Date input */}
<input
type="date"
{...register('birthDate', { valueAsDate: true })}
/>
{/* File input */}
<input type="file" {...register('avatar')} />
</form>
);
}
β Value Type Conversion
valueAsNumber- Convert to number (for type="number")valueAsDate- Convert to Date object (for type="date")setValueAs- Custom transformation function
Without these, all values are strings by default!
Custom Validation Functions
For complex validation logic, use the validate option with a custom function:
function CustomValidation() {
const { register, watch } = useForm<{
password: string;
confirmPassword: string;
username: string;
}>();
const password = watch('password');
return (
<form>
{/* Single validation function */}
<input
{...register('username', {
validate: (value) => {
if (value.toLowerCase() === 'admin') {
return 'Username "admin" is not allowed';
}
return true; // Valid
}
})}
/>
<input
type="password"
{...register('password')}
/>
{/* Validate against another field */}
<input
type="password"
{...register('confirmPassword', {
validate: (value) =>
value === password || 'Passwords do not match'
})}
/>
</form>
);
}
Multiple Custom Validators
You can have multiple validation functions for a single field:
function MultipleValidators() {
const { register } = useForm<{ email: string }>();
return (
<input
{...register('email', {
required: 'Email is required',
validate: {
// Named validators for specific error messages
matchPattern: (value) =>
/^\S+@\S+$/i.test(value) || 'Email must be valid',
notDisposable: (value) =>
!value.endsWith('@tempmail.com') || 'Disposable emails not allowed',
notBlacklisted: async (value) => {
const isBlacklisted = await checkEmailBlacklist(value);
return !isBlacklisted || 'This email is blacklisted';
}
}
})}
/>
);
}
π‘ Validation Function Return Values
true- Validation passedfalse- Validation failed (generic error)string- Validation failed with this error messagePromise<true | false | string>- Async validation
ποΈ Exercise 4: Custom Validation
Task: Create a password strength validator.
Requirements:
- Password must be at least 8 characters
- Must contain at least one uppercase letter
- Must contain at least one lowercase letter
- Must contain at least one number
- Must contain at least one special character (!@#$%^&*)
- Use multiple named validators
π‘ Hint
Use the validate option with an object containing named functions. Test each requirement with a regular expression.
β Solution
import { useForm } from 'react-hook-form';
interface PasswordFormData {
password: string;
}
function PasswordStrengthForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<PasswordFormData>();
const onSubmit = (data: PasswordFormData) => {
console.log('Strong password:', data.password);
alert('Password is strong!');
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '400px', margin: '2rem auto' }}>
<h2>Create Password</h2>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password" style={{ display: 'block', marginBottom: '0.5rem' }}>
Password
</label>
<input
id="password"
type="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters'
},
validate: {
hasUppercase: (value) =>
/[A-Z]/.test(value) || 'Password must contain an uppercase letter',
hasLowercase: (value) =>
/[a-z]/.test(value) || 'Password must contain a lowercase letter',
hasNumber: (value) =>
/\d/.test(value) || 'Password must contain a number',
hasSpecial: (value) =>
/[!@#$%^&*]/.test(value) || 'Password must contain a special character (!@#$%^&*)'
}
})}
style={{
width: '100%',
padding: '0.5rem',
border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`,
borderRadius: '4px'
}}
/>
{/* Show all password errors */}
{errors.password && (
<div style={{ marginTop: '0.5rem' }}>
<span style={{ color: '#f56565', fontSize: '0.875rem', display: 'block' }}>
{errors.password.message}
</span>
</div>
)}
{/* Password requirements checklist */}
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
<p style={{ marginBottom: '0.25rem' }}><strong>Requirements:</strong></p>
<ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
<li>At least 8 characters</li>
<li>One uppercase letter</li>
<li>One lowercase letter</li>
<li>One number</li>
<li>One special character (!@#$%^&*)</li>
</ul>
</div>
</div>
<button
type="submit"
style={{
width: '100%',
padding: '0.75rem',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Create Password
</button>
</form>
);
}
export default PasswordStrengthForm;
β Validation Rules
React Hook Form provides powerful validation capabilities. Let's explore all the validation options and strategies.
Validation Modes
Control when validation occurs by setting the mode option in useForm:
const { register } = useForm<FormData>({
mode: 'onSubmit', // Default: Validate on submit
// mode: 'onBlur', // Validate when field loses focus
// mode: 'onChange', // Validate on every change
// mode: 'onTouched',// Validate after blur, then on change
// mode: 'all' // Validate on blur and change
});
Validation Mode Comparison
| Mode | When It Validates | Best For |
|---|---|---|
onSubmit |
Only when form is submitted | Simple forms, less intrusive |
onBlur |
When field loses focus | Most forms, good UX balance |
onChange |
On every keystroke | Real-time feedback (password strength) |
onTouched |
After blur, then on every change | Best UX - validate after user leaves field |
all |
On blur AND on change | Maximum validation, can be annoying |
β Recommended Mode
For the best user experience, use mode: 'onTouched' or mode: 'onBlur':
- Doesn't annoy users while they're typing
- Validates after they finish with a field
- Provides immediate feedback on subsequent changes
- Balances helpfulness with intrusiveness
Advanced Validation Patterns
1. Dependent Field Validation
Validate one field based on another field's value:
function DependentValidation() {
const { register, watch } = useForm<{
startDate: string;
endDate: string;
minPrice: number;
maxPrice: number;
}>();
const startDate = watch('startDate');
const minPrice = watch('minPrice');
return (
<form>
<input type="date" {...register('startDate', { required: true })} />
<input
type="date"
{...register('endDate', {
required: 'End date is required',
validate: (value) => {
if (!startDate) return true;
return value >= startDate || 'End date must be after start date';
}
})}
/>
<input
type="number"
{...register('minPrice', {
required: true,
valueAsNumber: true,
min: { value: 0, message: 'Price must be positive' }
})}
/>
<input
type="number"
{...register('maxPrice', {
required: true,
valueAsNumber: true,
validate: (value) =>
value >= minPrice || 'Max price must be greater than min price'
})}
/>
</form>
);
}
2. Async Validation
Validate against a server (e.g., check username availability):
function AsyncValidation() {
const { register, formState: { errors } } = useForm<{ username: string }>();
// Simulate API call
const checkUsernameAvailability = async (username: string): Promise<boolean> => {
await new Promise(resolve => setTimeout(resolve, 500));
return !['admin', 'root', 'test'].includes(username.toLowerCase());
};
return (
<div>
<input
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'At least 3 characters' },
validate: async (value) => {
const isAvailable = await checkUsernameAvailability(value);
return isAvailable || 'Username is already taken';
}
})}
/>
{errors.username && <span>{errors.username.message}</span>}
</div>
);
}
β οΈ Async Validation Performance
Async validation can be expensive. Best practices:
- Use
mode: 'onBlur'to avoid validating on every keystroke - Debounce the validation if using
onChangemode - Show a loading indicator during validation
- Cache results to avoid duplicate API calls
3. Conditional Validation
Apply validation rules only when certain conditions are met:
function ConditionalValidation() {
const { register, watch } = useForm<{
needsVisa: boolean;
visaNumber: string;
country: string;
}>();
const needsVisa = watch('needsVisa');
const country = watch('country');
return (
<form>
<select {...register('country')}>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="cn">China</option>
</select>
<label>
<input type="checkbox" {...register('needsVisa')} />
I need a visa
</label>
{/* Only validate if checkbox is checked */}
{needsVisa && (
<input
{...register('visaNumber', {
required: needsVisa && 'Visa number is required',
pattern: needsVisa && country === 'us' ? {
value: /^\d{9}$/,
message: 'US visa must be 9 digits'
} : undefined
})}
/>
)}
</form>
);
}
4. Array Validation
Validate array fields (like multiple email addresses):
function ArrayValidation() {
const { register } = useForm<{ emails: string }>();
return (
<input
{...register('emails', {
required: 'At least one email is required',
validate: (value) => {
// Split comma-separated emails
const emails = value.split(',').map(e => e.trim());
// Check if all are valid
const emailRegex = /^\S+@\S+$/i;
const allValid = emails.every(email => emailRegex.test(email));
if (!allValid) {
return 'All emails must be valid';
}
// Check for duplicates
const unique = new Set(emails);
if (unique.size !== emails.length) {
return 'Duplicate emails are not allowed';
}
return true;
}
})}
placeholder="Enter emails separated by commas"
/>
);
}
Manual Validation Triggering
Sometimes you need to trigger validation programmatically:
function ManualValidation() {
const {
register,
trigger,
getValues,
formState: { errors }
} = useForm<{
email: string;
password: string;
}>();
const handleCheckEmail = async () => {
// Validate just the email field
const isValid = await trigger('email');
if (isValid) {
console.log('Email is valid:', getValues('email'));
}
};
const handleCheckAll = async () => {
// Validate all fields
const isValid = await trigger();
if (isValid) {
console.log('All fields are valid');
}
};
return (
<form>
<input
{...register('email', {
required: 'Email is required',
pattern: { value: /^\S+@\S+$/, message: 'Invalid email' }
})}
/>
<button type="button" onClick={handleCheckEmail}>
Check Email
</button>
<input
{...register('password', {
required: 'Password is required'
})}
/>
<button type="button" onClick={handleCheckAll}>
Validate All
</button>
{Object.keys(errors).length > 0 && (
<div style={{ color: 'red' }}>
{Object.keys(errors).length} error(s) found
</div>
)}
</form>
);
}
π‘ trigger() Use Cases
- Multi-step forms - Validate current step before proceeding
- Partial validation - Check specific fields on button click
- Debounced validation - Validate after user stops typing
- Manual testing - Validate on custom events
β Error Handling
React Hook Form makes error handling straightforward. Let's explore different ways to display and manage errors.
Accessing Errors
Errors are available in the formState.errors object:
function ErrorExample() {
const {
register,
formState: { errors }
} = useForm<{
username: string;
email: string;
}>();
return (
<form>
<input {...register('username', { required: 'Username is required' })} />
{/* Check if error exists */}
{errors.username && (
<span style={{ color: 'red' }}>
{errors.username.message}
</span>
)}
<input {...register('email', { required: 'Email is required' })} />
{/* Alternative: Optional chaining */}
{errors.email?.message && (
<span style={{ color: 'red' }}>
{errors.email.message}
</span>
)}
</form>
);
}
Error Object Structure
Each error has the following properties:
// Error object structure
type FieldError = {
type: string; // Type of error (e.g., 'required', 'minLength')
message?: string; // Error message
ref?: Ref; // Reference to the input element
};
// Example usage
if (errors.username) {
console.log(errors.username.type); // 'required'
console.log(errors.username.message); // 'Username is required'
}
Different Error Display Patterns
Pattern 1: Inline Errors (Most Common)
function InlineErrors() {
const {
register,
formState: { errors }
} = useForm<{ email: string }>();
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
{...register('email', {
required: 'Email is required',
pattern: { value: /^\S+@\S+$/, message: 'Invalid email' }
})}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby="email-error"
/>
{errors.email && (
<span
id="email-error"
role="alert"
style={{ color: '#f56565', fontSize: '0.875rem', display: 'block', marginTop: '0.25rem' }}
>
{errors.email.message}
</span>
)}
</div>
);
}
Pattern 2: Error Summary
function ErrorSummary() {
const {
register,
formState: { errors }
} = useForm<{
name: string;
email: string;
password: string;
}>();
const errorCount = Object.keys(errors).length;
return (
<form>
{/* Error summary at top */}
{errorCount > 0 && (
<div
role="alert"
style={{
background: '#ffebee',
border: '1px solid #f56565',
padding: '1rem',
borderRadius: '4px',
marginBottom: '1rem'
}}
>
<h3 style={{ margin: '0 0 0.5rem 0', color: '#f56565' }}>
β οΈ Please fix {errorCount} error{errorCount !== 1 ? 's' : ''}:
</h3>
<ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>
<strong>{field}:</strong> {error?.message}
</li>
))}
</ul>
</div>
)}
<input {...register('name', { required: 'Name is required' })} />
<input {...register('email', { required: 'Email is required' })} />
<input {...register('password', { required: 'Password is required' })} />
<button type="submit">Submit</button>
</form>
);
}
Pattern 3: Reusable Error Component
// Reusable error display component
interface ErrorMessageProps {
error?: FieldError;
}
function ErrorMessage({ error }: ErrorMessageProps) {
if (!error) return null;
return (
<span
role="alert"
style={{
color: '#f56565',
fontSize: '0.875rem',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
marginTop: '0.25rem'
}}
>
<span aria-hidden="true">β οΈ</span>
{error.message}
</span>
);
}
// Usage
function FormWithErrorComponent() {
const {
register,
formState: { errors }
} = useForm<{ username: string; email: string }>();
return (
<form>
<div>
<input {...register('username', { required: 'Username is required' })} />
<ErrorMessage error={errors.username} />
</div>
<div>
<input {...register('email', { required: 'Email is required' })} />
<ErrorMessage error={errors.email} />
</div>
</form>
);
}
Manual Error Management
You can manually set and clear errors:
function ManualErrors() {
const {
register,
handleSubmit,
setError,
clearErrors,
formState: { errors }
} = useForm<{ email: string; password: string }>();
const onSubmit = async (data: any) => {
try {
const response = await loginAPI(data);
if (!response.ok) {
// Set error manually
setError('email', {
type: 'manual',
message: 'Invalid credentials'
});
// Or set a root error (not tied to specific field)
setError('root.serverError', {
type: 'manual',
message: 'Server is unavailable. Please try again later.'
});
}
} catch (error) {
setError('root.serverError', {
type: 'manual',
message: 'Network error. Please check your connection.'
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Display root errors */}
{errors.root?.serverError && (
<div style={{ background: '#ffebee', padding: '1rem', marginBottom: '1rem' }}>
{errors.root.serverError.message}
</div>
)}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
<button type="submit">Login</button>
{/* Clear all errors */}
<button type="button" onClick={() => clearErrors()}>
Clear Errors
</button>
</form>
);
}
β Error Handling Best Practices
- Always use
aria-invalidon inputs with errors - Use
role="alert"on error messages for screen readers - Link errors to inputs with
aria-describedby - Show errors in red with sufficient contrast (4.5:1)
- Include helpful, specific error messages
- Clear errors when user starts fixing them
- Use icons for visual feedback (not just color)
ποΈ Exercise 5: Complete Form with Errors
Task: Build a profile update form with comprehensive error handling.
Requirements:
- Fields: name, email, age, bio
- All fields required with appropriate validation
- Show inline errors below each field
- Show error summary at top if errors exist
- Disable submit button if form has errors
- Simulate server error on submit and display it
π‘ Hint
Access errors from formState. Count errors with Object.keys(errors).length. Use setError for server errors in the submit handler.
β Solution
import { useForm } from 'react-hook-form';
interface ProfileFormData {
name: string;
email: string;
age: number;
bio: string;
}
function ProfileUpdateForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isValid, isSubmitting }
} = useForm<ProfileFormData>({
mode: 'onBlur',
defaultValues: {
name: '',
email: '',
age: 18,
bio: ''
}
});
const onSubmit = async (data: ProfileFormData) => {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Simulate server error randomly
if (Math.random() > 0.5) {
setError('root.serverError', {
type: 'manual',
message: 'Failed to update profile. Please try again.'
});
return;
}
console.log('Profile updated:', data);
alert('Profile updated successfully!');
} catch (error) {
setError('root.serverError', {
type: 'manual',
message: 'Network error. Please check your connection.'
});
}
};
const errorCount = Object.keys(errors).filter(key => key !== 'root').length;
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
<h2>Update Profile</h2>
{/* Error Summary */}
{errorCount > 0 && (
<div
role="alert"
style={{
background: '#ffebee',
border: '1px solid #f56565',
padding: '1rem',
borderRadius: '4px',
marginBottom: '1rem'
}}
>
<h3 style={{ margin: '0 0 0.5rem 0', color: '#f56565' }}>
β οΈ Please fix {errorCount} error{errorCount !== 1 ? 's' : ''}
</h3>
</div>
)}
{/* Server Error */}
{errors.root?.serverError && (
<div
role="alert"
style={{
background: '#ffebee',
border: '1px solid #f56565',
padding: '1rem',
borderRadius: '4px',
marginBottom: '1rem',
color: '#f56565'
}}
>
{errors.root.serverError.message}
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="name">Name *</label>
<input
id="name"
{...register('name', {
required: 'Name is required',
minLength: { value: 2, message: 'Name must be at least 2 characters' }
})}
aria-invalid={errors.name ? 'true' : 'false'}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.name ? '#f56565' : '#ddd'}`
}}
/>
{errors.name && (
<span role="alert" style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.name.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email">Email *</label>
<input
id="email"
type="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Invalid email address'
}
})}
aria-invalid={errors.email ? 'true' : 'false'}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
}}
/>
{errors.email && (
<span role="alert" style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.email.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="age">Age *</label>
<input
id="age"
type="number"
{...register('age', {
required: 'Age is required',
valueAsNumber: true,
min: { value: 13, message: 'Must be at least 13 years old' },
max: { value: 120, message: 'Please enter a valid age' }
})}
aria-invalid={errors.age ? 'true' : 'false'}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.age ? '#f56565' : '#ddd'}`
}}
/>
{errors.age && (
<span role="alert" style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.age.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="bio">Bio *</label>
<textarea
id="bio"
{...register('bio', {
required: 'Bio is required',
minLength: { value: 10, message: 'Bio must be at least 10 characters' },
maxLength: { value: 200, message: 'Bio must be less than 200 characters' }
})}
rows={4}
aria-invalid={errors.bio ? 'true' : 'false'}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
fontFamily: 'inherit',
border: `1px solid ${errors.bio ? '#f56565' : '#ddd'}`
}}
/>
{errors.bio && (
<span role="alert" style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.bio.message}
</span>
)}
</div>
<button
type="submit"
disabled={!isValid || isSubmitting}
style={{
width: '100%',
padding: '0.75rem',
background: (isValid && !isSubmitting) ? '#667eea' : '#ddd',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: (isValid && !isSubmitting) ? 'pointer' : 'not-allowed'
}}
>
{isSubmitting ? 'Updating...' : 'Update Profile'}
</button>
</form>
);
}
export default ProfileUpdateForm;
π· TypeScript Integration
React Hook Form has excellent TypeScript support. Let's explore advanced typing patterns to make your forms completely type-safe.
Basic Type Definition
We've already seen basic typing with useForm<FormData>. Let's go deeper:
// Define your form structure
interface UserFormData {
name: string;
email: string;
age: number;
role: 'admin' | 'user' | 'guest'; // Union type
preferences: {
newsletter: boolean;
notifications: boolean;
};
tags: string[];
}
function TypedForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<UserFormData>({
defaultValues: {
name: '',
email: '',
age: 18,
role: 'user',
preferences: {
newsletter: false,
notifications: true
},
tags: []
}
});
// onSubmit is fully typed!
const onSubmit = (data: UserFormData) => {
console.log(data.name); // β
string
console.log(data.age); // β
number
console.log(data.role); // β
'admin' | 'user' | 'guest'
console.log(data.preferences); // β
{ newsletter: boolean; notifications: boolean }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* TypeScript knows these field names exist */}
<input {...register('name')} />
<input {...register('email')} />
<input {...register('age', { valueAsNumber: true })} />
<select {...register('role')}>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="guest">Guest</option>
</select>
<button type="submit">Submit</button>
</form>
);
}
Type Inference
TypeScript can infer types from your default values:
// TypeScript infers the form type from defaultValues
function InferredForm() {
const {
register,
handleSubmit
} = useForm({
defaultValues: {
username: '', // inferred as string
age: 0, // inferred as number
isActive: false, // inferred as boolean
tags: [] as string[] // need to specify array type
}
});
// data is automatically typed!
const onSubmit = (data) => {
console.log(data.username); // TypeScript knows this is a string
console.log(data.age); // TypeScript knows this is a number
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
<input {...register('age', { valueAsNumber: true })} />
<button type="submit">Submit</button>
</form>
);
}
Nested Form Data
Type nested objects and arrays properly:
interface Address {
street: string;
city: string;
country: string;
zipCode: string;
}
interface WorkExperience {
company: string;
position: string;
years: number;
}
interface ComplexFormData {
personalInfo: {
firstName: string;
lastName: string;
birthDate: Date;
};
address: Address;
workHistory: WorkExperience[];
skills: string[];
}
function NestedForm() {
const { register, handleSubmit } = useForm<ComplexFormData>();
const onSubmit = (data: ComplexFormData) => {
// All nested properties are typed!
console.log(data.personalInfo.firstName);
console.log(data.address.city);
console.log(data.workHistory[0].company);
console.log(data.skills);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Nested object notation */}
<input {...register('personalInfo.firstName')} />
<input {...register('personalInfo.lastName')} />
{/* Nested address */}
<input {...register('address.street')} />
<input {...register('address.city')} />
{/* Arrays with index */}
<input {...register('workHistory.0.company')} />
<input {...register('workHistory.0.position')} />
<button type="submit">Submit</button>
</form>
);
}
Generic Form Component
Create reusable form components that work with any data type:
import { useForm, FieldValues, UseFormReturn } from 'react-hook-form';
// Generic form component
interface FormProps<TFormData extends FieldValues> {
onSubmit: (data: TFormData) => void;
defaultValues: TFormData;
children: (methods: UseFormReturn<TFormData>) => React.ReactNode;
}
function GenericForm<TFormData extends FieldValues>({
onSubmit,
defaultValues,
children
}: FormProps<TFormData>) {
const methods = useForm<TFormData>({ defaultValues });
return (
<form onSubmit={methods.handleSubmit(onSubmit)}>
{children(methods)}
</form>
);
}
// Usage with different data types
interface LoginData {
email: string;
password: string;
}
function LoginPage() {
const handleLogin = (data: LoginData) => {
console.log('Login:', data);
};
return (
<GenericForm
onSubmit={handleLogin}
defaultValues={{ email: '', password: '' }}
>
{({ register, formState: { errors } }) => (
<>
<input {...register('email', { required: true })} />
{errors.email && <span>Email required</span>}
<input type="password" {...register('password', { required: true })} />
{errors.password && <span>Password required</span>}
<button type="submit">Login</button>
</>
)}
</GenericForm>
);
}
Strict Mode TypeScript
Make field names completely type-safe:
interface StrictFormData {
username: string;
email: string;
age: number;
}
function StrictForm() {
const { register } = useForm<StrictFormData>();
return (
<form>
{/* β
TypeScript knows these are valid */}
<input {...register('username')} />
<input {...register('email')} />
<input {...register('age')} />
{/* β TypeScript error: 'password' doesn't exist on StrictFormData */}
{/* <input {...register('password')} /> */}
</form>
);
}
β TypeScript Benefits Summary
- Autocomplete - IDE suggests valid field names
- Type safety - Catch typos at compile time
- Refactoring - Rename fields safely across your app
- Documentation - Types document your form structure
- IntelliSense - See field types while coding
- Fewer bugs - Many errors caught before runtime
Typing Validation Rules
Create typed validation helpers:
// Type-safe validation rules
type ValidationRules<T> = {
[K in keyof T]?: {
required?: string | boolean;
minLength?: { value: number; message: string };
maxLength?: { value: number; message: string };
pattern?: { value: RegExp; message: string };
validate?: (value: T[K]) => boolean | string;
};
};
interface UserFormData {
username: string;
email: string;
age: number;
}
// Define validation rules with full type safety
const validationRules: ValidationRules<UserFormData> = {
username: {
required: 'Username is required',
minLength: { value: 3, message: 'At least 3 characters' }
},
email: {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Invalid email'
}
},
age: {
required: 'Age is required',
validate: (value) => value >= 18 || 'Must be 18 or older'
}
};
function TypedValidationForm() {
const { register } = useForm<UserFormData>();
return (
<form>
<input {...register('username', validationRules.username)} />
<input {...register('email', validationRules.email)} />
<input {...register('age', validationRules.age)} />
</form>
);
}
π‘ Advanced TypeScript Patterns
- Use
Partial<FormData>for optional field updates - Use
Pick<FormData, 'field1' | 'field2'>for subsets - Use
Omit<FormData, 'field'>to exclude fields - Use
Record<string, any>for dynamic forms - Use
as constfor literal types in validation
π€ Form Submission
Let's explore advanced form submission patterns, including loading states, success/error handling, and data transformation.
Basic Submission
The handleSubmit function wraps your submit handler:
function BasicSubmission() {
const { register, handleSubmit } = useForm<{ name: string }>();
// Your submit handler - only called if validation passes
const onSubmit = (data: { name: string }) => {
console.log('Form data:', data);
// data is already validated and typed!
};
// Optional: Handle validation errors
const onError = (errors: any) => {
console.log('Validation errors:', errors);
};
return (
<form onSubmit={handleSubmit(onSubmit, onError)}>
<input {...register('name', { required: true })} />
<button type="submit">Submit</button>
</form>
);
}
Async Submission with Loading State
Handle asynchronous operations and show loading feedback:
function AsyncSubmission() {
const {
register,
handleSubmit,
formState: { isSubmitting, isSubmitSuccessful },
reset
} = useForm<{ email: string }>();
const onSubmit = async (data: { email: string }) => {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error('Subscription failed');
}
// Reset form on success
reset();
alert('Successfully subscribed!');
} catch (error) {
alert('Subscription failed. Please try again.');
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', { required: true })}
disabled={isSubmitting}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Subscribing...' : 'Subscribe'}
</button>
{isSubmitSuccessful && (
<p style={{ color: 'green' }}>β Successfully subscribed!</p>
)}
</form>
);
}
Data Transformation Before Submit
Transform form data before sending to the server:
interface FormInput {
firstName: string;
lastName: string;
birthDate: string;
tags: string; // comma-separated
}
interface APIPayload {
fullName: string;
birthDate: Date;
tags: string[];
}
function TransformationForm() {
const { register, handleSubmit } = useForm<FormInput>();
const onSubmit = (data: FormInput) => {
// Transform data for API
const payload: APIPayload = {
fullName: `${data.firstName} ${data.lastName}`,
birthDate: new Date(data.birthDate),
tags: data.tags.split(',').map(tag => tag.trim())
};
console.log('Transformed payload:', payload);
// Send payload to API
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('firstName')} placeholder="First Name" />
<input {...register('lastName')} placeholder="Last Name" />
<input {...register('birthDate')} type="date" />
<input {...register('tags')} placeholder="tag1, tag2, tag3" />
<button type="submit">Submit</button>
</form>
);
}
Complete Submission Flow
A production-ready form with all submission states:
interface ContactFormData {
name: string;
email: string;
message: string;
}
function CompleteSubmissionForm() {
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setError
} = useForm<ContactFormData>({
mode: 'onBlur'
});
const onSubmit = async (data: ContactFormData) => {
try {
setSubmitStatus('idle');
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error('Failed to send message');
}
// Success
setSubmitStatus('success');
reset();
// Hide success message after 5 seconds
setTimeout(() => setSubmitStatus('idle'), 5000);
} catch (error) {
setSubmitStatus('error');
setError('root.serverError', {
type: 'manual',
message: 'Failed to send message. Please try again later.'
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
<h2>Contact Us</h2>
{/* Success Message */}
{submitStatus === 'success' && (
<div
style={{
background: '#e8f5e9',
border: '1px solid #4CAF50',
color: '#2e7d32',
padding: '1rem',
borderRadius: '4px',
marginBottom: '1rem'
}}
>
β Message sent successfully! We'll get back to you soon.
</div>
)}
{/* Error Message */}
{errors.root?.serverError && (
<div
style={{
background: '#ffebee',
border: '1px solid #f56565',
color: '#f56565',
padding: '1rem',
borderRadius: '4px',
marginBottom: '1rem'
}}
>
β οΈ {errors.root.serverError.message}
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="name">Name *</label>
<input
id="name"
{...register('name', {
required: 'Name is required',
minLength: { value: 2, message: 'Name must be at least 2 characters' }
})}
disabled={isSubmitting}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.name ? '#f56565' : '#ddd'}`
}}
/>
{errors.name && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.name.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email">Email *</label>
<input
id="email"
type="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Invalid email address'
}
})}
disabled={isSubmitting}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
}}
/>
{errors.email && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.email.message}
</span>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="message">Message *</label>
<textarea
id="message"
{...register('message', {
required: 'Message is required',
minLength: { value: 10, message: 'Message must be at least 10 characters' }
})}
rows={5}
disabled={isSubmitting}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
fontFamily: 'inherit',
border: `1px solid ${errors.message ? '#f56565' : '#ddd'}`
}}
/>
{errors.message && (
<span style={{ color: '#f56565', fontSize: '0.875rem' }}>
{errors.message.message}
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
style={{
width: '100%',
padding: '0.75rem',
background: isSubmitting ? '#ddd' : '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isSubmitting ? 'not-allowed' : 'pointer',
fontSize: '1rem',
fontWeight: 'bold'
}}
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
β Form Submission Best Practices
- Disable inputs during submission - Prevent changes while processing
- Show loading state - Update button text or show spinner
- Handle errors gracefully - Display user-friendly error messages
- Provide success feedback - Confirm submission with visual feedback
- Reset form after success - Clear fields for new submission
- Validate before submit - Use mode: 'onBlur' or 'onTouched'
- Transform data if needed - Match API requirements
- Use TypeScript - Type both form data and API payloads
π Summary
Congratulations! You've mastered React Hook Form. Let's recap what you've learned:
Key Takeaways
- β Why React Hook Form - 90% less code, better performance, excellent DX
- β Installation - Simple npm install, works with any React project
- β useForm Hook - Returns register, handleSubmit, formState, and more
- β Registration - Spread {...register('fieldName')} on inputs
- β Validation - Built-in rules (required, min, max, pattern) + custom validators
- β Error Handling - Automatic error tracking with formState.errors
- β TypeScript - Full type safety for form data and validation
- β Submission - Async support, loading states, error handling
React Hook Form vs Vanilla React
| Feature | Vanilla React | React Hook Form |
|---|---|---|
| Code for simple form | ~100 lines | ~20 lines |
| Performance | Re-renders on every keystroke | No re-renders (uncontrolled) |
| Validation | Manual functions | Built-in + custom |
| TypeScript | Manual typing | Automatic inference |
| Learning curve | Moderate | Easy |
| Bundle size | 0KB (vanilla) | ~9KB (minified + gzipped) |
When to Use React Hook Form
β Perfect For:
- Any form with 3+ fields
- Forms requiring validation
- Complex forms with nested data
- Performance-critical applications
- TypeScript projects
- Forms that will grow over time
β οΈ Consider Vanilla React When:
- Single field forms (search boxes)
- You need controlled inputs for specific reasons
- Bundle size is extremely critical (<9KB matters)
- Quick prototypes where setup time matters
Quick Reference
Common Patterns Cheat Sheet
// 1. Basic setup
const { register, handleSubmit } = useForm<FormData>();
// 2. Register with validation
<input {...register('email', {
required: 'Email is required',
pattern: { value: /^\S+@\S+$/, message: 'Invalid email' }
})} />
// 3. Show errors
{errors.email && <span>{errors.email.message}</span>}
// 4. Watch field values
const password = watch('password');
// 5. Custom validation
validate: (value) => value === password || 'Passwords must match'
// 6. Async validation
validate: async (value) => {
const available = await checkAvailability(value);
return available || 'Already taken';
}
// 7. Submit handler
const onSubmit = async (data: FormData) => {
await saveData(data);
reset();
};
// 8. Manual errors
setError('email', { type: 'manual', message: 'Server error' });
// 9. Trigger validation
await trigger('email'); // Validate single field
await trigger(); // Validate all fields
π― What's Next?
In the upcoming lessons, you'll learn:
- Lesson 7.3: Form Validation with Zod - Schema-based validation for even more power
- Lesson 7.4: File Uploads - Handle files, images, and previews
- Lesson 7.5: Advanced Form Patterns - Dynamic fields, multi-step wizards, and more
React Hook Form integrates beautifully with Zod, which you'll learn in the next lesson!
πͺ You've Learned
- How React Hook Form simplifies form handling dramatically
- The useForm hook and all its utilities
- How to register inputs and add validation
- Built-in and custom validation strategies
- Multiple error display patterns
- Advanced TypeScript integration for type safety
- Production-ready form submission patterns
- When to use React Hook Form vs vanilla React
You're now ready to build professional, performant forms with React Hook Form!