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

React Hook Form validation data flow register attaches a ref to the input. On the configured mode event the value is validated by the resolver or validate functions. Failures populate formState.errors, which drives the accessible error render with aria-invalid and aria-describedby. register() ref + name validate resolver / fn formState .errors accessible render aria-invalid / describedby on mode event re-validate on next event (reValidateMode)
register binds the ref; the configured mode triggers validation; failures land in formState.errors and drive the accessible render. Subsequent keystrokes re-validate per reValidateMode.

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.

← Back to Framework Integration Patterns

Explore This Section