React 74 ⚛️ Form Validation with React Hook Form
Forms are where React’s rendering model meets the messy reality of user input, and React Hook Form (RHF) has become the default library for handling that intersection. Its core insight is architectural: instead of storing every keystroke in React state and re-rendering the entire form, RHF registers inputs as uncontrolled components and uses refs to read their values. The result is a form that re-renders only when validation errors or submission state change, not on every character typed . The library ships with zero dependencies and a bundle size around 9KB minified, which is why it is widely adopted for both simple and complex forms .
The validation story has two layers. The first is built-in rules passed to register(): required, minLength, pattern, validate, and others that mirror HTML constraint validation but run on RHF’s schedule . The second is schema validation through resolvers, most commonly Zod. A Zod schema defines the shape and constraints of the form data, and zodResolver connects it to RHF, giving you a single source of truth that can be shared with the backend . The choice between the two depends on complexity: for a three-field login form, register rules are sufficient; for nested objects, conditional fields, or data that will be validated again on the server, a schema is the better investment.
This chapter covers the core useForm API, built-in validation with register, schema validation with Zod, and the performance patterns that keep forms responsive as they grow.
Key point: React Hook Form uses uncontrolled inputs registered via register() to minimize re-renders; validation can be defined inline with register rules or externally through a Zod schema connected with zodResolver, and handleSubmit only invokes your callback when all validation passes.
Why React Hook Form exists
The re-render problem. A controlled form stores every input value in React state. Every keystroke triggers a state update, which re-renders the component and every child. For a form with twenty fields and a heavy component tree, typing becomes noticeably laggy. RHF sidesteps this by keeping values in the DOM and reading them through refs. The form component does not re-render on input change unless a validation error or submission state changes .
The validation timing problem. Different forms need different validation schedules. A login form should validate on submit to avoid flashing errors while the user is still typing. A registration form with a password strength meter needs to validate on change. RHF exposes this through the mode option: onSubmit (the default), onBlur, onChange, onTouched, or all . The default is deliberately the least noisy: errors appear only after the user tries to submit.
The schema problem. Inline validation rules are scattered across every register call. The form’s shape is implicit in those calls, and the same rules must be duplicated on the server. A Zod schema makes the shape explicit, provides TypeScript types via z.infer, and can be reused on the backend. The zodResolver bridges the two: RHF calls the resolver on validation, the resolver runs the Zod schema, and RHF maps the resulting errors back to fields .
The error display problem. RHF’s formState.errors is a nested object that mirrors the form structure. errors.email.message is the error for the email field. When fields are nested—user.address.city—the errors object nests the same way. This makes rendering error messages straightforward, but it also means that destructuring formState incorrectly can subscribe to every error change and cause unnecessary re-renders .
The submission problem. handleSubmit validates before calling your callback. If validation fails, it calls the optional onError handler instead. If it passes, the callback receives the typed data. Since v7.84.0, the promise returned by handleSubmit resolves with the callback’s return value, which makes it possible to chain logic after a successful submit . Async submission state is exposed through formState.isSubmitting, which is useful for disabling the submit button while a request is in flight.
a. The useForm hook and register
useForm is the entry point. It returns an object with register, handleSubmit, formState, watch, setValue, and other utilities. The generic parameter types the form values:
import { useForm } from 'react-hook-form'
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm({
defaultValues: { email: '', password: '' },
mode: 'onSubmit'
})
const onSubmit = (data) => {
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Email is required' })} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password', { minLength: { value: 8, message: 'Too short' } })} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>Log in</button>
</form>
)
}
The spread {...register('email')} attaches name, onChange, onBlur, and ref to the input. This is how RHF connects the DOM element to its internal state without React state . The second argument to register is an options object containing validation rules. These rules are not native HTML attributes; they run on RHF’s validation schedule. If you want them forwarded as native attributes for progressive enhancement, pass progressive: true to useForm .
The rules include required, minLength, maxLength, min, max, pattern, and validate. The validate function can return true for valid, a string for an error message, or false for a generic error. It can also be an object of multiple validators, each with its own message .
defaultValues is not optional in practice. Without it, RHF treats inputs as uncontrolled and may emit warnings when a value becomes defined. It also ensures the form starts with the correct initial state for isDirty and reset . The recommended pattern is to always provide defaultValues, even if they are empty strings.
b. Schema validation with Zod
When the form has nested data, conditional fields, or validation that must match the backend, a Zod schema is the cleaner approach. The setup requires two packages: zod and @hookform/resolvers .
npm install zod @hookform/resolvers
The schema defines the shape and validation. z.infer extracts the TypeScript type:
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters')
})
type LoginData = z.infer<typeof loginSchema>
The useForm hook receives the resolver and the inferred type:
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' }
})
const onSubmit = (data) => {
// data is typed as LoginData
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Submit</button>
</form>
)
}
The error messages come from the schema, not from the register call. This is the key difference: register has no validation rules in the schema approach. The resolver handles all validation, and RHF maps the Zod errors to formState.errors .
Zod’s coerce is useful for form inputs that return strings but should be numbers. z.coerce.number().min(18) converts the string input to a number before validating, which avoids the common bug of comparing a string to a number . For conditional fields, z.discriminatedUnion or z.refine can express rules that depend on other field values.
c. Performance patterns and common pitfalls
The biggest performance mistake is destructuring formState incorrectly. const { formState } = useForm() subscribes to every change in the form state object, causing re-renders on every keystroke. Destructuring specific properties—const { errors, isSubmitting } = formState—subscribes only to those properties, and RHF’s proxy-based optimization prevents unnecessary re-renders . Reading formState.isValid inline inside handleSubmit rather than destructuring it at the top of the component is the recommended pattern for large forms .
watch() is another source of re-renders. Calling watch() with no arguments returns all form values and re-renders the component on every change. watch('email') subscribes only to that field. For components that need to react to a field value without re-rendering the parent form, useWatch({ control, name: 'email' }) isolates the subscription to that component . The same principle applies to Controller: it is necessary for UI libraries that do not expose a ref (like some date pickers), but for standard inputs, register is faster because it stays uncontrolled.
useFieldArray requires field.id as the React key, not the array index. Using the index causes React to reuse DOM nodes incorrectly when items are inserted or removed, leading to stale values and focus bugs . The fields array from useFieldArray contains the id property for exactly this purpose.
A large-form caveat: with 300+ fields, a resolver, and destructured formState, RHF can freeze for several seconds during initial registration. The workarounds are to read formState properties inline, use mode: 'onSubmit' to avoid validating on every change, and split the form into sub-forms if possible .
Complete Example Session
The following session builds a registration form with Zod validation, nested fields, and a custom password confirmation check.
// ============================================
// PART 1: IMPORTS AND SCHEMA
// ============================================
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const registerSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
})
type RegisterData = z.infer<typeof registerSchema>
// ============================================
// PART 2: COMPONENT SETUP
// ============================================
function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm({
resolver: zodResolver(registerSchema),
defaultValues: {
username: '',
email: '',
password: '',
confirmPassword: ''
},
mode: 'onBlur'
})
// ============================================
// PART 3: SUBMIT HANDLER
// ============================================
const onSubmit = async (data) => {
try {
await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
} catch (error) {
// Server error handling would go here
}
}
// ============================================
// PART 4: FORM MARKUP
// ============================================
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="username">Username</label>
<input id="username" {...register('username')} />
{errors.username && <span role="alert">{errors.username.message}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && <span role="alert">{errors.email.message}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} />
{errors.password && <span role="alert">{errors.password.message}</span>}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input id="confirmPassword" type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span role="alert">{errors.confirmPassword.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
)
}
The schema defines all constraints, including the refine check that compares two fields. The path option in refine tells Zod which field the error belongs to, so errors.confirmPassword receives the “Passwords do not match” message. The form uses mode: 'onBlur', which validates when the user leaves a field rather than on every keystroke. The submit handler is async and guarded by isSubmitting.
Quick Reference
useForm Options
| Option | Default | Purpose |
|---|---|---|
defaultValues | undefined | Initial values; required for controlled behavior |
mode | 'onSubmit' | Validation trigger: onSubmit, onBlur, onChange, all |
resolver | undefined | Schema resolver (e.g., zodResolver(schema)) |
shouldUnregister | false | Remove field data when input unmounts |
register Validation Rules
| Rule | Example |
|---|---|
required | { required: 'This field is required' } |
minLength | { minLength: { value: 8, message: 'Too short' } } |
pattern | { pattern: { value: /^\d+$/, message: 'Numbers only' } } |
validate | { validate: (v) => v !== 'admin' || 'Reserved' } |
Zod + RHF Integration
| Piece | Code |
|---|---|
| Install | npm install zod @hookform/resolvers |
| Schema | z.object({ email: z.string().email() }) |
| Type | type Data = z.infer<typeof schema> |
| Resolver | useForm({ resolver: zodResolver(schema) }) |
Performance Rules
| Rule | Why |
|---|---|
Destructure formState properties | Avoids re-render on every state change |
watch('field') not watch() | Isolates subscription |
useWatch in child components | Prevents parent re-renders |
field.id as key in useFieldArray | Prevents stale DOM nodes |
Best Practices
✅ Do This:
useForm({ defaultValues: { email: '' } }) // ✅ always set defaults
const { errors } = formState // ✅ destructure specific prop
watch('email') // ✅ watch one field
<Controller control={control} name="date" render={...} /> // ✅ for non-ref UI libs
{fields.map((f) => <div key={f.id}>...</div>)} // ✅ useFieldArray key
❌ Don’t Do This:
useForm() // ❌ no defaultValues
const { formState } = useForm() // ❌ subscribes to everything
watch() // ❌ re-renders on any change
<input value={state} onChange={...} /> // ❌ controlled input with RHF
{fields.map((f, i) => <div key={i}>...</div>)} // ❌ index as key
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Warning about controlled/uncontrolled | Missing defaultValues | Always provide defaultValues |
| Form re-renders on every keystroke | watch() with no args | watch('field') or useWatch |
| Zod error not appearing | Schema field name mismatch | Check field names in schema and register |
isSubmitting always false | Not destructured from formState | const { isSubmitting } = formState |
useFieldArray values shift on delete | Index used as key | Use field.id |
| Validation runs too often | mode: 'onChange' with expensive schema | Use onSubmit or onBlur |
Real-World Examples
1. Basic required field
<input {...register('name', { required: 'Name is required' })} />
2. Email pattern
{ pattern: { value: /^\S+@\S+$/, message: 'Invalid email' } }
3. Zod schema with min length
z.object({ password: z.string().min(8) })
4. Password confirmation with refine
.refine((d) => d.password === d.confirm, { path: ['confirm'], message: 'Mismatch' })
5. Async submit state
<button disabled={isSubmitting}>Submit</button>
6. Server error display
setError('root.serverError', { message: 'Server failed' })
7. Conditional field validation
z.object({ hasPet: z.boolean(), petName: z.string().optional() })
.refine((d) => !d.hasPet || d.petName, { path: ['petName'], message: 'Required' })
8. Nested field path
<input {...register('address.city')} />
9. Controller for custom component
<Controller control={control} name="date" render={({ field }) => <DatePicker {...field} />} />
10. Reset after submit
reset() // inside onSubmit after success
Visual
Controlled vs Uncontrolled Re-renders
┌──────────────────────────────────────────────┐
│ CONTROLLED (React state) │
│ keystroke → setState → re-render form │
│ keystroke → setState → re-render form │
│ ... every character re-renders │
│ │
│ RHF (uncontrolled + refs) │
│ keystroke → DOM updates directly │
│ no React re-render unless validation changes│
│ re-render only on error or submit state │
└──────────────────────────────────────────────┘
Zod + RHF Flow
┌──────────────────────────────────────────────┐
│ User submits form │
│ │ │
│ ▼ │
│ handleSubmit runs │
│ │ │
│ ▼ │
│ zodResolver calls Zod schema │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ valid invalid │
│ │ │ │
│ ▼ ▼ │
│ onSubmit errors populate │
│ callback formState.errors │
│ │ │
│ ▼ │
│ UI displays messages │
└──────────────────────────────────────────────┘
register Return Spread
┌──────────────────────────────────────────────┐
│ register('email') returns: │
│ { │
│ name: 'email', │
│ onChange: fn, │
│ onBlur: fn, │
│ ref: fn │
│ } │
│ │ │
│ ▼ │
│ <input {...register('email')} /> │
│ │ │
│ ▼ │
│ RHF connects to DOM without React state │
└──────────────────────────────────────────────┘
Validation Modes
┌──────────────────────────────────────────────┐
│ onSubmit → validate on submit only │
│ onBlur → validate when field loses focus │
│ onChange → validate every keystroke │
│ onTouched → after first blur, then change │
│ all → blur + change + submit │
│ │
│ Default: onSubmit (least noisy) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Core hook | useForm() |
| Input registration | register('name', rules) |
| Submit handler | handleSubmit(onSubmit) |
| Form state | formState.errors, formState.isSubmitting |
| Validation rules | required, minLength, pattern, validate |
| Schema resolver | zodResolver(schema) |
| Schema type | z.infer<typeof schema> |
| Default validation mode | onSubmit |
| Re-render minimization | Uncontrolled inputs + refs |
| Bundle size | ~9KB, zero dependencies |
Key takeaways:
- RHF minimizes re-renders by keeping inputs uncontrolled. Values live in the DOM, and React re-renders only when validation or submission state changes .
defaultValuesis required in practice. Without it, RHF cannot distinguish controlled from uncontrolled andisDirtydoes not work correctly.- Two validation approaches exist.
registerrules are inline and simple; Zod schemas are external, reusable, and type-safe. Choose based on complexity . zodResolvermaps schema errors toformState.errors. The field names in the schema must match the field names inregister.- Destructure
formStateproperties, not the object.const { errors } = formStatesubscribes only to errors;const { formState }subscribes to everything . useWatchisolates subscriptions. For components that need a field value without re-rendering the parent form,useWatch({ control, name })is the pattern .field.idis the key foruseFieldArray. Index keys cause stale values when items are inserted or removed .
Remember: React Hook Form’s value proposition is performance through uncontrolled inputs, and its validation story is flexible through register rules and Zod resolvers. Start with defaultValues, choose a validation mode that matches the form’s needs, and decide early whether the form is simple enough for inline rules or complex enough to warrant a schema. Destructure formState carefully, use useWatch for isolated reads, and always key useFieldArray items by field.id. The library handles the rest—error mapping, submission state, and the ref wiring that keeps typing smooth.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!