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 |
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.
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