Skip to content

How to fix: React Hook Form validation across multiple tabs: is useWatch + custom validation the right approach?

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.

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

  1. Keep all tabs mounted but hidden via display: none.
  2. 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);
};

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 component
const { invalid, isDirty } = getFieldState("tab1Field", formState);
return (
<Tab style={{ color: invalid ? 'red' : 'black' }}>
Account Info {invalid && "!"}
</Tab>
);
  • Avoid shouldUnregister: true: In multi-step forms, ensure this is false (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 useWatch for validation. Schema validation is declarative, easier to test, and centralized.
  • Use useFormContext: For deeply nested tab components, wrap your form in a FormProvider. This allows child tab components to access register and trigger without prop drilling.
  • Focus Management: When a user tries to submit and there is an error in a hidden tab, use the setFocus API 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.