How to fix: React Hook Form validation across multiple tabs: is useWatch + custom validation the right approach?
🚨 Understanding the Error
Section titled “🚨 Understanding the Error”In complex React applications, splitting a large form into multiple tabs often leads to State Fragmentation. The primary challenge is ensuring that fields hidden in inactive tabs are still validated, tracked, and correctly submitted.
Developers often reach for useWatch combined with custom validation logic because they want to “listen” to changes in Tab A while the user is in Tab B. However, this is typically a sub-optimal approach. useWatch is designed for UI synchronization (e.g., showing/hiding a component based on a value), not for enforcing business logic or validation constraints. Relying on it for validation leads to high re-render overhead and brittle code that bypasses the built-in React Hook Form (RHF) validation lifecycle.
🔍 Root Cause
Section titled “🔍 Root Cause”The issue usually stems from how RHF handles unmounted components and the distinction between “UI state” and “Form state.”
| Issue | Description | Impact |
|---|---|---|
| Unmounting Fields | By default, RHF may unregister fields when they are removed from the DOM if shouldUnregister is set to true. |
Form data and validation rules for hidden tabs are lost. |
| Trigger Latency | Validation is usually triggered on blur or change. Hidden tabs don’t receive these events. |
The form appears valid even if hidden tabs contain errors. |
| Manual Validation Logic | Using useWatch to manually calculate isValid for a tab bypasses the resolver. |
Inconsistent error messages and duplicate validation logic. |
| Re-render Bloat | useWatch triggers a re-render of the component it is used in whenever any watched value changes. |
Performance degradation in large, complex forms. |
🛠️ Step-by-Step Solutions
Section titled “🛠️ Step-by-Step Solutions”Solution 1: Use a Centralized Schema (Recommended)
Section titled “Solution 1: Use a Centralized Schema (Recommended)”Instead of using useWatch, define a global validation schema (Zod or Yup) and use the trigger method. This allows you to validate specific fields belonging to a tab before allowing the user to switch.
import { useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import * as z from "zod";
const schema = z.object({ tab1Field: z.string().min(3), tab2Field: z.string().email(),});
const MultiTabForm = () => { const { register, trigger, formState: { errors } } = useForm({ resolver: zodResolver(schema), mode: "onChange", // Or "onTouched" shouldUnregister: false, // CRITICAL: Keep data of hidden tabs });
const handleTabChange = async (nextTab: number) => { // Validate only the fields in the current tab before moving const isTabValid = await trigger(["tab1Field"]); if (isTabValid) { // Proceed to next tab } };
return ( <form> {/* Use CSS to hide/show tabs rather than conditional rendering if you want to keep elements in the DOM */} <div style={{ display: currentTab === 0 ? 'block' : 'none' }}> <input {...register("tab1Field")} /> {errors.tab1Field && <span>{errors.tab1Field.message}</span>} </div> </form> );};Solution 2: Scoped Validation with trigger
Section titled “Solution 2: Scoped Validation with trigger”If your form is too large for a single schema, you can manually trigger validation for a “field array” or a group of fields associated with a specific tab index.
- Keep all tabs mounted but hidden via
display: none. - Use
<kbd>trigger(['field1', 'field2'])</kbd>on the “Next” button.
const nextStep = async () => { const fieldsToValidate = tabIndex === 0 ? ['username', 'email'] : ['address', 'city']; const output = await trigger(fieldsToValidate as any);
if (output) setTabIndex(prev => prev + 1);};Solution 3: The getFieldState Alternative
Section titled “Solution 3: The getFieldState Alternative”Instead of useWatch, which subscribes to value changes, use getFieldState to check the validity of a tab’s fields without forcing unnecessary re-renders.
const { getFieldState, formState } = useForm();
// Inside your Tab Header componentconst { invalid, isDirty } = getFieldState("tab1Field", formState);
return ( <Tab style={{ color: invalid ? 'red' : 'black' }}> Account Info {invalid && "!"} </Tab>);🛡️ Prevention and Best Practices
Section titled “🛡️ Prevention and Best Practices”- Avoid
shouldUnregister: true: In multi-step forms, ensure this isfalse(default) so that RHF retains the values and validation status of fields that are not currently visible in the DOM. - CSS vs. Logical Gates: Prefer hiding tabs with
display: none(CSS) rather than{activeTab === 1 && <TabOne />}. This ensures the inputs remain registered and the DOM nodes are available for native browser validation if needed. - Schema Over Watch: Always prefer a Zod/Yup resolver over
useWatchfor validation. Schema validation is declarative, easier to test, and centralized. - Use
useFormContext: For deeply nested tab components, wrap your form in aFormProvider. This allows child tab components to accessregisterandtriggerwithout prop drilling. - Focus Management: When a user tries to submit and there is an error in a hidden tab, use the
setFocusAPI to programmatically switch to that tab and focus the invalid input.
To navigate to an error, you can use:
const { setFocus } = useForm(); followed by setFocus(“fieldName”); inside an onError callback in handleSubmit.