react hook form
Form validation and submission for React
$ npx docs2skills add react-hook-formReact Hook Form
Performant forms with easy validation and minimal re-renders
What this skill does
React Hook Form is a library that simplifies form handling in React by embracing uncontrolled components and HTML standards. It reduces the number of re-renders compared to controlled components, improving performance especially for large forms.
The library provides built-in form validation aligned with HTML5 standards, seamless integration with UI libraries through Controller components, and excellent TypeScript support. It eliminates the need for complex state management while providing powerful validation, error handling, and submission capabilities with minimal boilerplate code.
Prerequisites
- React 16.8+ (hooks support)
- For schema validation:
@hookform/resolvers+ schema library (Yup, Zod, etc.) - For UI libraries: May need specific adapters or Controller wrapper
Quick start
npm install react-hook-form
import { useForm } from "react-hook-form"
type FormData = {
firstName: string
email: string
}
export default function App() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>()
const onSubmit = (data: FormData) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("firstName", { required: true })} />
{errors.firstName && <span>First name is required</span>}
<input {...register("email", {
required: "Email is required",
pattern: {
value: /^\S+@\S+$/i,
message: "Invalid email"
}
})} />
{errors.email && <span>{errors.email.message}</span>}
<input type="submit" />
</form>
)
}
Core concepts
Uncontrolled Components: Form inputs maintain their own state, registered with the form through refs. This reduces re-renders compared to controlled components.
Registration: Each input must be registered using register() with a unique name. This connects the input to the form's validation and submission system.
Validation: Built-in HTML5 validation rules (required, min, max, pattern) plus custom validation functions. Validation runs on submit by default.
Form State: Centralized state management through formState object containing errors, touched fields, dirty state, and submission status.
Key API surface
// Primary hook
const {
register, // Register input: register("name", options)
handleSubmit, // Form submission wrapper
formState, // { errors, isSubmitting, isDirty, isValid }
control, // For Controller components
watch, // Watch field values: watch("fieldName")
setValue, // Programmatically set values
getValues, // Get current form values
reset, // Reset form to defaults
trigger, // Manually trigger validation
clearErrors, // Clear specific errors
setError // Set custom errors
} = useForm<T>(options)
// For UI library integration
<Controller
name="fieldName"
control={control}
rules={{ required: true }}
render={({ field, fieldState }) => (
<CustomInput {...field} error={fieldState.error} />
)}
/>
// Form component for API integration
<Form
action="/api/endpoint"
control={control}
onSuccess={handleSuccess}
onError={handleError}
>
Common patterns
Basic registration with validation:
<input {...register("username", {
required: "Username is required",
minLength: { value: 3, message: "Min 3 characters" },
validate: value => value !== "admin" || "Username unavailable"
})} />
UI library integration:
import { Controller } from "react-hook-form"
import { TextField } from "@mui/material"
<Controller
name="description"
control={control}
rules={{ required: true }}
render={({ field, fieldState }) => (
<TextField
{...field}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
Schema validation with Zod:
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const schema = z.object({
email: z.string().email(),
age: z.number().min(18)
})
const { register, handleSubmit } = useForm({
resolver: zodResolver(schema)
})
Custom validation and error handling:
const {
register,
handleSubmit,
setError,
formState: { errors }
} = useForm()
const onSubmit = async (data) => {
try {
await submitForm(data)
} catch (error) {
if (error.field) {
setError(error.field, { message: error.message })
}
}
}
Watching form values:
const watchedValues = watch(["firstName", "lastName"])
const firstName = watch("firstName")
// Watch all fields
const allValues = watch()
Configuration
useForm({
mode: "onChange", // When to validate: onChange, onBlur, onSubmit
reValidateMode: "onChange", // When to re-validate after errors
defaultValues: {}, // Initial form values
resolver: zodResolver(schema), // External validation schema
context: {}, // Context for validation
criteriaMode: "firstError", // Return first error or all errors
shouldFocusError: true, // Auto-focus first error field
delayError: 0, // Delay error display
shouldUseNativeValidation: false, // Use browser validation
shouldUnregister: false // Keep field values when unmounted
})
Best practices
- Use TypeScript: Define form data types for better developer experience and validation
- Uncontrolled first: Use
register()for native inputs,Controlleronly when necessary - Validation on submit: Default behavior is optimal; change validation mode only if needed
- Schema validation: Use Yup/Zod for complex validation instead of inline rules
- Error boundaries: Handle form submission errors gracefully with proper error states
- Default values: Always provide
defaultValuesfor better form UX and avoiding undefined - Form state optimization: Use
formStatedestructuring to prevent unnecessary re-renders - Watch selectively: Only watch specific fields you need to avoid performance issues
- Reset after submission: Call
reset()after successful form submission for better UX - Accessibility: Use proper ARIA attributes and error associations for screen readers
Gotchas and common mistakes
Field names must be unique strings - Each registered field needs a unique name property for the registration process.
Default values vs controlled components - Don't mix value prop with register(). Use defaultValue for uncontrolled or Controller for controlled.
Watch causes re-renders - watch() triggers component re-renders. Use getValues() in event handlers for one-time reads.
Validation mode affects UX - mode: "onChange" validates on every keystroke which can be jarring. Consider onBlur or onSubmit.
Async validation timing - Custom validate functions with async operations need proper error handling and loading states.
Form reset behavior - reset() without arguments uses initial defaultValues, not current form state.
Nested object field names - Use dot notation for nested fields: register("user.address.street").
Array field complexity - Dynamic arrays need useFieldArray hook, not manual array manipulation.
Controller render function - Always spread field props and handle fieldState for proper integration.
Uncontrolled to controlled warning - Switching between undefined and defined values triggers React warnings. Always use defaultValues.
Schema validation overrides - When using resolver, inline validation rules in register() are ignored.
Form submission prevents default - handleSubmit automatically prevents default form submission; don't call e.preventDefault() again.
React Native differences - Use Controller for all React Native inputs as they don't support refs the same way.