react-hook-form
React Hook Form - Performant Form Management
Build performant, flexible forms with easy validation
When to Use
Use React Hook Form when you need:
- Form validation with schema-based validation (Zod, Yup)
- Performance - minimizes re-renders with uncontrolled inputs
- Complex forms with field arrays, nested objects, or conditional fields
- Developer experience - TypeScript support and minimal boilerplate
- Integration with UI libraries like shadcn/ui, Material UI
Choose alternatives when:
- Simple forms with 1-2 fields (native HTML might suffice)
- You need full controlled inputs for every keystroke (use useState)
- Building non-React forms
Critical Patterns
Pattern 1: Schema-First Validation
// ✅ Good: Define schema first, infer types
const formSchema = z.object({
email: z.string().email('Invalid email'),
age: z.number().min(18, 'Must be 18+'),
role: z.enum(['admin', 'user']),
});
type FormData = z.infer<typeof formSchema>;
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: { email: '', age: 0, role: 'user' },
});
// ❌ Bad: Manual type definitions, sync issues
interface FormData {
email: string;
age: number;
}
const form = useForm<FormData>();
const validate = (data: FormData) => {
const errors: Record<string, string> = {};
if (!data.email.includes('@')) errors.email = 'Invalid';
return errors;
};
Why: Schema-first ensures single source of truth, better type safety, and automatic validation.
Pattern 2: Proper Error Display
// ✅ Good: Check existence before displaying
{errors.email && (
<span className="text-red-500 text-sm">
{errors.email.message}
</span>
)}
// ✅ Good: With shadcn/ui FormMessage handles it
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage /> {/* Automatically shows errors */}
</FormItem>
)}
/>
// ❌ Bad: No null check, crashes if no error
<span>{errors.email.message}</span>
// ❌ Bad: Generic error message loses context
{errors.email && <span>Error occurred</span>}
Why: Proper error checking prevents runtime errors; specific messages improve UX.
Pattern 3: Field Arrays for Dynamic Lists
For field arrays and dynamic forms, see references/advanced.md.
Pattern 4: Controlled vs Uncontrolled
// ✅ Good: Uncontrolled (default) - best performance
<input {...register('email')} />
// ✅ Good: Controlled when you need the value reactively
const email = watch('email');
useEffect(() => {
console.log('Email changed:', email);
}, [email]);
// ✅ Good: Controller for third-party components
import { Controller } from 'react-hook-form';
<Controller
name="date"
control={form.control}
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
/>
)}
/>
// ❌ Bad: Making all fields controlled unnecessarily
const email = watch('email');
<input
value={email}
onChange={(e) => setValue('email', e.target.value)}
/>
// ❌ Bad: Third-party component without Controller
<DatePicker {...register('date')} />
Why: Uncontrolled inputs minimize re-renders; use controlled only when necessary; Controller properly integrates third-party components.
Pattern 5: Form Submission with Loading States
// ✅ Good: Use isSubmitting, handle errors, show feedback
const onSubmit = async (data: FormData) => {
try {
await api.createUser(data);
toast.success('User created successfully');
form.reset();
} catch (error) {
toast.error('Failed to create user');
// Optionally set form errors
form.setError('root', {
message: 'Server error occurred',
});
}
};
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* fields */}
<button
type="submit"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? 'Creating...' : 'Create User'}
</button>
</form>
// ❌ Bad: No loading state, no error handling
const onSubmit = async (data: FormData) => {
await api.createUser(data);
};
<button type="submit">Submit</button>
// ❌ Bad: Manual loading state, out of sync
const [loading, setLoading] = useState(false);
const onSubmit = async (data: FormData) => {
setLoading(true);
await api.createUser(data);
setLoading(false);
};
Why: Built-in isSubmitting syncs with form state; proper error handling improves reliability.
Anti-Patterns
Anti-Pattern 1: Not Using defaultValues
// ❌ Problem: Uncontrolled inputs without defaults cause issues
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
// No defaultValues
});
// Form state is undefined, causes bugs with reset, dirty checking
Why it's wrong: React Hook Form tracks changes from initial state; without defaults, isDirty, reset(), and validation may behave unexpectedly.
Solution:
// ✅ Always provide defaultValues
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
name: '',
age: 0,
role: 'user',
},
});
// ✅ Or use async defaultValues for fetched data
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: async () => {
const user = await fetchUser();
return user;
},
});
Anti-Pattern 2: Destructuring register
// ❌ Problem: Destructuring breaks the ref
const { onChange, onBlur, name } = register('email');
<input onChange={onChange} onBlur={onBlur} name={name} />
// ❌ Problem: Spreading twice loses the ref
<input {...form.register('email')} {...otherProps} />
Why it's wrong: register returns a ref that must be attached; destructuring or overriding loses the connection.
Solution:
// ✅ Spread register directly
<input {...register('email')} />
// ✅ Add props before register spread
<input placeholder="Email" {...register('email')} />
// ✅ For custom props that might conflict, use register options
<input
{...register('email', {
onChange: (e) => {
// custom logic
},
})}
/>
Anti-Pattern 3: Validation in onChange
// ❌ Problem: Triggering validation on every keystroke
<input
{...register('email')}
onChange={(e) => {
form.trigger('email'); // Validates on every key
}}
/>
Why it's wrong: Creates poor UX (errors show immediately) and performance issues.
Solution:
// ✅ Use mode configuration for validation timing
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
mode: 'onBlur', // Validate on blur, not onChange
reValidateMode: 'onChange', // Re-validate onChange after first error
});
// ✅ Manual trigger only when needed (e.g., dependent fields)
const password = watch('password');
useEffect(() => {
if (form.formState.touchedFields.confirmPassword) {
form.trigger('confirmPassword');
}
}, [password]);
For more anti-patterns (setState vs setValue, form state, resetting forms), see references/advanced.md.
What This Skill Covers
- Form registration and state management
- Validation with Zod resolver
- Field arrays for dynamic forms
- Form submission and error handling
For advanced patterns, see references/.
Basic Form
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type FormData = z.infer<typeof formSchema>;
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
});
const onSubmit = async (data: FormData) => {
await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>Login</button>
</form>
);
}
With shadcn/ui
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
export function UserForm() {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}
Quick Reference
// Create form
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: '' },
});
// Register input
<input {...form.register('name')} />
// Handle submit
form.handleSubmit(onSubmit)
// Get errors
form.formState.errors.name?.message
// Set value
form.setValue('name', 'John')
// Watch value
const name = form.watch('name')
Learn More
- Advanced Patterns: references/advanced.md - Field arrays, controlled components, complex validation
External References
Maintained by dsmj-ai-toolkit