React Hook Form Validation: useForm, Resolvers, and Accessible Errors
React Hook Form validates uncontrolled inputs through refs, runs rules at a configurable moment via mode, and exposes failures through a single subscribed formState.errors object. This guide covers the full lifecycle — useForm, register, validation modes, the Zod resolver, async field validation, imperative setError/clearErrors, reuse of native constraints, and the ARIA wiring that keeps the result accessible.
The Problem React Hook Form Solves
Controlled-component forms re-render on every keystroke: each character updates state, which re-renders the field, which can cascade through a large form. React Hook Form sidesteps this by leaving inputs uncontrolled — the DOM node holds the value, RHF holds a ref to it, and React only re-renders when validation status actually changes. The library is the reactive projection of native validity described in the parent Framework Integration Patterns guide: it subscribes to DOM events, mirrors the result into formState, and lets you keep native attribute constraints on the very same <input>.
Prerequisites
| Requirement | Version / note |
|---|---|
react |
18+ (for useId and concurrent-safe state) |
react-hook-form |
7.45+ |
zod |
3.22+ (for schema validation) |
@hookform/resolvers |
3.x (the zodResolver adapter) |
| TypeScript | 5.x, strict: true |
useForm and register API Reference
| API | Purpose |
|---|---|
useForm<Values>(options) |
Creates the form instance; options.mode, options.resolver, options.defaultValues |
register(name, rules?) |
Returns { name, onChange, onBlur, ref } to spread onto an input |
handleSubmit(onValid, onInvalid?) |
Wraps the submit handler; runs validation first |
formState.errors |
Map of field name → FieldError ({ type, message }) |
formState.isValidating |
true while an async validator is pending |
formState.isSubmitting |
true during the async onValid handler |
setError(name, error) |
Imperatively set a field error (e.g. server response) |
clearErrors(name?) |
Remove one or all errors |
watch(name) |
Subscribe to a field’s value (re-renders on change) |
setValue / reset |
Programmatic value/state control |
Step-by-Step Implementation
Step 1 — Create the typed form instance and choose a mode
mode decides when validation first runs for a field; reValidateMode decides when it re-runs after the first error. The default onSubmit mode is the least noisy and the most accessible — it avoids flagging fields the user has not finished, echoing the timing guidance in UX Patterns & Error State Design.
import { useForm } from 'react-hook-form';
type LoginValues = { email: string; password: string };
export function LoginForm() {
const {
register, handleSubmit, formState: { errors, isSubmitting },
} = useForm<LoginValues>({
mode: 'onBlur', // validate a field when it loses focus
reValidateMode: 'onChange', // after an error, re-check on each keystroke
defaultValues: { email: '', password: '' },
});
// ...
}
mode value |
First validation runs |
|---|---|
onSubmit (default) |
When the form is submitted |
onBlur |
When a field loses focus |
onChange |
On every keystroke (use sparingly — noisy) |
onTouched |
First on blur, then on change |
all |
On both blur and change |
Step 2 — Register fields with inline rules and native constraints
register returns the props to spread onto a native input. You can supply RHF rules and keep native attributes on the same element — the browser’s first-pass constraints, described in the Constraint Validation API Deep Dive, still gate the form, and RHF’s messages render on top.
<input
type="email"
required // native first pass, SSR-safe
{...register('email', {
required: 'Email is required',
pattern: { value: /^[^@\s]+@[^@\s]+\.[^@\s]+$/, message: 'Enter a valid email' },
})}
/>
Step 3 — Validate a whole-form schema with a resolver
For anything beyond per-field rules — cross-field checks, shared server schemas — pass a resolver. The zodResolver runs the schema and translates the ZodError into formState.errors. The full typed wiring lives in Integrating the Zod Resolver with React Hook Form.
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const Schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
});
type Values = z.infer<typeof Schema>;
const { register, handleSubmit, formState: { errors } } =
useForm<Values>({ resolver: zodResolver(Schema), mode: 'onBlur' });
When both a resolver and inline register rules are present, the resolver wins — keep your rules in one place to avoid surprising overrides.
Step 4 — Render errors with the ARIA trio
formState.errors[name] is a FieldError with a message. Wire each field with aria-invalid and aria-describedby, and render the message in a live region so screen readers announce it.
import { useId } from 'react';
function EmailField({ register, error }: {
register: ReturnType<typeof useForm<Values>>['register'];
error?: { message?: string };
}) {
const id = useId();
const errId = `${id}-err`;
return (
<div className="form-group">
<label htmlFor={id}>Email</label>
<input
id={id}
type="email"
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errId : undefined}
{...register('email')}
/>
<p id={errId} role="alert" className="error-container">{error?.message}</p>
</div>
);
}
Step 5 — Handle async validation and submission
handleSubmit only calls onValid when validation passes. Make it async to await the server; isSubmitting drives the button’s loading state.
const onValid = async (values: Values) => {
const res = await fetch('/api/login', {
method: 'POST', body: JSON.stringify(values),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
setError('email', { type: 'server', message: 'Invalid credentials' });
}
};
return (
<form noValidate onSubmit={handleSubmit(onValid)}>
{/* fields */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
);
Note noValidate on the <form>: it suppresses the browser’s blocking popups while keeping the Constraint Validation API available, matching the site’s canonical pattern.
State Management & Edge Cases
Async field validation with debounce and cancellation
Field-level async rules (username availability) go in register’s validate. Return a Promise<true | string>. Debounce the input and cancel stale requests with an AbortController, as covered in asynchronous server checks; the RHF-specific recipe is React Hook Form Async Field Validation.
register('username', {
validate: async (value) => {
const taken = await checkUsernameTaken(value); // debounced + abortable
return taken ? 'That username is taken' : true;
},
});
While the promise is pending, formState.isValidating is true — drive a spinner from it and keep the submit button disabled to prevent submitting against a stale result.
Server errors via setError and clearErrors
A 400 from the server is not known at validation time. Map the response into the form with setError, and clear it with clearErrors when the field changes so the stale server message does not persist.
catch {
setError('email', { type: 'server', message: 'Email already registered' });
}
// later, on change:
register('email', { onChange: () => clearErrors('email') });
The defaultValues / reset race
If defaultValues arrive asynchronously (from a fetch), pass them to reset(serverData) once loaded rather than relying on the initial useForm call — otherwise the uncontrolled inputs keep their empty initial values.
Accessibility Compliance
| WCAG SC | Obligation in RHF |
|---|---|
| 3.3.1 Error Identification | Set aria-invalid="true" whenever errors[name] exists |
| 3.3.3 Error Suggestion | Put a corrective hint in the message, not just “invalid” |
| 1.3.1 Info & Relationships | Link the error to the input via aria-describedby |
| 4.1.3 Status Messages | Render messages in role="alert" / aria-live="polite" |
| 2.4.3 Focus Order | On submit failure, focus the first invalid field |
RHF exposes setFocus(name) so an onInvalid handler can move focus to the first failing control. Use a stable useId-derived id so aria-describedby survives re-renders and SSR hydration.
Common Gotchas
Reading formState outside the render. formState is a Proxy that tracks which keys you access to decide what to re-render. Destructuring it inside render (as above) subscribes correctly; reading it inside a useEffect or callback may give a stale snapshot. Read what you need during render.
// Before — isSubmitting never updates the button:
const onClick = () => { if (formState.isSubmitting) return; };
// After — subscribe during render:
const { isSubmitting } = formState;
Forgetting noValidate. Without it, the browser shows its own popup and RHF renders an error, producing duplicate, unstyled messaging.
Spreading register before custom handlers. If you add your own onChange, spread register first so RHF’s handler is not clobbered — or compose both.
// After — both handlers run:
<input {...register('q', { onChange: (e) => analytics(e.target.value) })} />
Browser Compatibility
| Concern | Support |
|---|---|
| Uncontrolled inputs + refs | All evergreen browsers; React 18 SSR-safe |
useId for stable ARIA ids |
React 18+ only — required for hydration-safe aria-describedby |
Native pattern / required alongside RHF |
Universal; acts as progressive-enhancement first pass |
AbortController for async validators |
All evergreen browsers; abort cancels the pending fetch |
Bridging Controlled Components with Controller and useController
register works by attaching a ref to a native DOM node, which means it only speaks the vocabulary of <input>, <select>, and <textarea>. The moment you reach for a third-party widget — a Material UI TextField, a react-select combobox, a masked-currency input, a date picker — that assumption breaks. Those components own their own internal state and expose a value/onChange contract rather than a forwardable ref you can validate against. RHF’s answer is the Controller component (and its hook form, useController), which subscribes to a single field and hands you the props to wire into any controlled component without dragging the rest of the form into its render cycle.
The distinction matters for a reason that is easy to miss: Controller deliberately makes that one field controlled, so it will re-render on every keystroke — but only that field. The surrounding form stays uncontrolled and untouched. You are paying the controlled-component cost exactly where the widget demands it and nowhere else, which is the whole point.
import { useController, type Control } from 'react-hook-form';
type Values = { country: string };
function CountrySelect({ control }: { control: Control<Values> }) {
const {
field: { value, onChange, onBlur, ref, name },
fieldState: { error, isTouched },
} = useController({
control,
name: 'country',
rules: { required: 'Choose a country' },
});
return (
<div className="form-group">
<label htmlFor={name}>Country</label>
<select
id={name}
name={name}
ref={ref}
value={value}
onChange={onChange} // RHF expects the raw value, not the event
onBlur={onBlur} // drives onBlur / onTouched modes
aria-invalid={error ? 'true' : undefined}
>
<option value="">Select…</option>
<option value="us">United States</option>
<option value="de">Germany</option>
</select>
{isTouched && error && (
<p role="alert" className="error-container">{error.message}</p>
)}
</div>
);
}
Note that fieldState is scoped to this field alone — reading error and isTouched here does not subscribe the component to the whole formState Proxy, so a validation change on an unrelated field will not re-render this select. When a widget emits something other than a DOM event (for example a Date object or a { label, value } option), remember to translate it inside your own onChange before calling the field’s onChange, because RHF stores whatever value you pass it verbatim.
Managing Dynamic Field Sets with useFieldArray
Forms that grow at runtime — invoice line items, a variable list of email addresses, repeatable address blocks — cannot be expressed as a fixed defaultValues shape. useFieldArray manages an ordered collection under a single field name and gives you append, remove, insert, move, and swap operators that keep RHF’s internal registration in sync as the array mutates.
The one non-obvious rule is the key prop. useFieldArray returns a fields array whose entries each carry a generated id; you must use that id as the React key, never the array index. Keying on the index re-associates DOM nodes with the wrong data when a middle row is removed, which in an uncontrolled form silently leaks the removed row’s value into its neighbour.
import { useForm, useFieldArray } from 'react-hook-form';
type Invoice = {
lineItems: { description: string; amount: number }[];
};
function LineItems() {
const { control, register, formState: { errors } } = useForm<Invoice>({
defaultValues: { lineItems: [{ description: '', amount: 0 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'lineItems' });
return (
<fieldset>
<legend>Line items</legend>
{fields.map((field, index) => (
<div key={field.id} className="form-row">
<input
aria-label={`Item ${index + 1} description`}
{...register(`lineItems.${index}.description`, {
required: 'Description required',
})}
/>
<input
type="number"
aria-label={`Item ${index + 1} amount`}
{...register(`lineItems.${index}.amount`, {
valueAsNumber: true,
min: { value: 0, message: 'Must be positive' },
})}
/>
{errors.lineItems?.[index]?.amount && (
<span role="alert">{errors.lineItems[index]!.amount!.message}</span>
)}
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ description: '', amount: 0 })}>
Add item
</button>
</fieldset>
);
}
The bracket-and-dot path syntax (lineItems.${index}.amount) is how RHF names nested and array fields internally; the same path flows through into errors.lineItems[index].amount, so your rendering code mirrors your registration code. Because the array lives in RHF’s store rather than component state, append and remove do not force the whole form to re-render — only the array wrapper re-renders to add or drop a row.
Cross-Field Rules Without Whole-Form Re-Renders
A recurring source of accidental re-render storms is validating one field against another — the classic “confirm password must equal password”. The naive fix is watch('password'), but watch subscribes the entire component to that value, so every keystroke in the password field re-renders the whole form even when nothing else changed. Two better options exist depending on when you need the value.
If you only need the other field’s value at the moment of validation, read it lazily with getValues, which does not subscribe and therefore triggers no re-render:
register('confirmPassword', {
validate: (value, formValues) =>
value === formValues.password || 'Passwords do not match',
});
RHF passes the full formValues object as the second argument to every validate function precisely so you can avoid watch for this case. If instead you need to react to another field live — say, revealing a “state” dropdown only when country === 'us' — scope the subscription with the useWatch hook inside a small child component so only that subtree re-renders, leaving the rest of the form static.
Trimming Bundle Cost and Re-Render Count
RHF’s headline is that it is small and re-render-thrifty, but both properties are easy to forfeit. On bundle size, the resolver adapters are the main lever: importing zodResolver pulls in Zod, which is not trivial. If a route only needs a couple of simple rules, prefer inline register rules over a schema and skip the resolver entirely; reserve schemas for forms that genuinely share a contract with the server.
On re-renders, the guiding principle is to subscribe as narrowly as the UI truly requires. Reading formState.isValid opts you into a re-render on every validation state change across the form, because computing validity requires RHF to run all rules eagerly — this is often the hidden cause of a form that re-renders far more than expected. If you only need to disable a submit button, isSubmitting alone is usually enough. When a genuinely isolated piece of UI needs a single field’s state, prefer useController/useFormState with a name filter over reading the top-level formState, so an unrelated field’s error does not ripple outward. Measure with the React DevTools “Highlight updates” overlay: a correctly wired RHF form should flash only the field you are typing into.
Frequently Asked Questions
When should I use mode: 'onBlur' versus 'onChange'?
Prefer onBlur (or the default onSubmit) for first validation — it avoids
flagging a field the user is still typing into. Use reValidateMode: 'onChange' so that
once an error appears, it clears responsively as the user fixes it. Reserve pure onChange
for short, format-strict fields.
Do I still need native required if RHF validates?
It is worth keeping. Native attributes ship in the server HTML and gate submission before React
hydrates, preserving progressive enhancement. Add noValidate on the form so the browser
popup is suppressed while RHF renders the styled, accessible message.
Why does my button's loading state not update from isSubmitting?
formState is a Proxy that only re-renders for keys you read during render. Destructure
const { isSubmitting } = formState in the render body rather than reading it inside a
callback or effect, where you would get a stale snapshot.
How do I show a server-side error after submission?
Call setError(name, { type: 'server', message }) in the catch branch of your submit
handler, and clearErrors(name) when the field next changes so the stale message does not
linger.
Why does removing a row from useFieldArray shift the wrong values?
You are almost certainly keying the mapped rows on the array index. Use field.id from the
fields array as the React key instead. With uncontrolled inputs, an index key
lets React reuse the removed row's DOM node for its neighbour, so the value appears to jump rows.
Should I use register or Controller for a Material UI field?
Use Controller (or useController). register needs a forwardable ref
to a native DOM node, which most component-library inputs do not expose in a way RHF can validate.
Controller makes just that one field controlled and wires its value/onChange
contract, leaving the rest of the form uncontrolled.
Related Guides
- Integrating the Zod Resolver with React Hook Form — typed schema wiring and error mapping
- React Hook Form Async Field Validation — debounced, abortable username checks
- Schema-Based Validation with Zod — the schema the resolver consumes
- Asynchronous Server Checks — the state machine behind async field rules
- Constraint Validation API Deep Dive — the native constraints RHF layers on top of
← Back to Framework Integration Patterns