Beyond checking whether a single field is present, real forms raise strategic questions: when should an error appear, how do you validate one field against another, and how do you validate against a server? This lesson covers validation timing, cross-field rules, and debounced async validation.
1Beyond a Single Required Check
Validating whether a single field is present and correctly formatted is a starting point, not the whole picture. Real forms need strategic decisions about when errors should appear, how to validate one field relative to another, and how to handle rules requiring a server round-trip.
2Validation Timing: onChange vs. onBlur vs. onSubmit
onChange validation on every keystroke can feel intrusive while a user is still typing. onBlur validation, firing when a field loses focus, is usually the best default — it catches mistakes without interrupting active typing. onSubmit-only validation is simplest to implement but delays all feedback until the very end.
3Cross-Field Validation
Some fields, like a password confirmation, can only be validated relative to another field's current value, not in isolation. A validate function that reads another field's live value (e.g. via watch) implements this kind of relational validation rule.
4Async Validation
Some validation rules, like checking whether a username is already taken, depend on a server response that can't be determined synchronously. An async validate function returning a Promise lets the validation framework wait for that server round-trip before deciding whether the field is valid.
5Debouncing Async Validation
Validating on every keystroke against a server would fire one network request per character typed, which is wasteful and slow. Debouncing the async check — waiting for a brief pause in typing before firing the actual request — keeps the experience responsive while avoiding a flood of redundant calls.
6Step-by-Step Breakdown
Beyond a Single Required Check. You've already validated a single field's presence and format. Real forms raise harder questions: WHEN should an error appear — as you type, when you leave the field, or only on submit? How do you validate one field against another, like a password confirmation? This lesson covers those strategic decisions.
Validation Timing: onChange vs. onBlur vs. onSubmit. onChange validation (every keystroke) feels naggy for something like a password field mid-typing. onBlur validation (when the user leaves the field) is usually the sweet spot: it doesn't interrupt typing but still catches mistakes before the user moves on. onSubmit-only validation is simplest but delays all feedback to the very end.
Why is onBlur validation often considered the best default for most form fields?
- →It catches mistakes without interrupting the user mid-keystroke, unlike onChange
- →It requires the least amount of validation code to implement
Cross-Field Validation. A password confirmation field is only valid relative to another field's current value — you can't validate it in isolation. React Hook Form's validate function (or watch) can access other fields' current values to implement exactly this kind of relational rule.
Async Validation. Some validation rules require a server round-trip — checking whether a username is already taken, for instance. A validate function can return a Promise; React Hook Form waits for it to resolve before deciding whether the field is valid, showing a pending state in the meantime.
Why does checking whether a username is already taken need an async validation function instead of a synchronous one?
- →The answer depends on a server response that isn't known synchronously
- →Async functions always validate faster than sync ones
Debouncing Async Validation. Validating on every keystroke against a server would fire a request per character typed — wasteful and slow. Debouncing the async check (waiting for a pause in typing before firing the request) keeps the UX responsive while avoiding a flood of redundant network calls.
Mastery Achieved. You now have a real validation strategy toolkit: choosing between onChange, onBlur, and onSubmit timing, cross-field validation for related fields, async validation for server-dependent rules, and debouncing to keep those async checks efficient. Next, you'll learn Zod, a schema-based way to define all of this validation logic in one declarative place.
Level Up 🚀
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Announce Validation Errors as They Appear
Whatever timing strategy is chosen, newly appearing error messages should be inside an element with role='alert' or an aria-live region, so screen reader users are notified of the error without needing to re-navigate to find it.
2Async Validation Needs an Accessible Pending Indicator
While an async validation check (like a username availability lookup) is in flight, communicate that pending state accessibly (e.g. aria-busy or a visually and programmatically indicated 'Checking...' state) rather than leaving the field in an ambiguous state.
SEO Implications
- 1
Validation Strategy Has No Direct SEO Effect
This is a client-side form UX concern, affecting interaction after hydration, with no direct bearing on server-rendered content or crawlability.
Best Practices
Default to onBlur Timing Unless There's a Specific Reason Not To
onBlur strikes the best general balance between timely feedback and not being intrusive — reserve onChange validation for cases with a specific UX reason, like a live character-count limit.
Always Debounce Server-Dependent Validation
Any validate function making a network call should be debounced, typically 300-500ms, to avoid firing a request on every keystroke and overwhelming the backend or feeling janky.
Frequent Bugs
An async username-availability check fires a network request on every single keystroke.
The validate function isn't debounced. Wrap the actual network-calling logic in a debounce utility so it only fires after a brief pause in typing.
A password confirmation field never shows an error even when the two passwords clearly don't match.
The confirmation field's validate function isn't correctly reading the other field's live value — ensure it uses something like watch('password') to compare against the current value of the related field, not a stale snapshot.
Real-World Examples
A Signup Form with Debounced Username Availability
A signup form needs to check username availability against the server as the user types, without hammering the API on every keystroke. Debouncing the async validate function to fire 400ms after the user stops typing, combined with a 'Checking availability...' pending indicator, provides responsive feedback without excessive requests.
const debouncedCheck = useMemo(
() => debounce(async (value, resolve) => {
const taken = await checkUsernameTaken(value);
resolve(!taken || 'Username is already taken');
}, 400),
[]
);
<input {...register('username', {
validate: (value) => new Promise((resolve) => debouncedCheck(value, resolve)),
})} />