Conditional fields
Show, hide, and validate fields based on other answers.
Read the controlling value with useWatch and render the dependent input only when it applies. The harder decision is what happens to a field's value and rules while it is hidden. Decide that per field.
Hidden fields: keep, remove, or skip
When an input unmounts, React Hook Form stops running its built-in rules, so a hidden required field never blocks submission. What happens to its value depends on how you hide it:
| Approach | Hidden value submitted? | Value back when shown again? | Use when |
|---|---|---|---|
| Unmount (default) | Yes, the last value | Yes | The value still applies, such as an earlier step in a wizard |
Unmount with shouldUnregister: true | No | No, it starts empty | The answer no longer applies and should be discarded |
Keep mounted with disabled: true | No (undefined) | Yes | The input stays visible but inactive, and the answer may apply again |
The default is the one that surprises people: a user picks "Business", types a company name, switches back to "Personal", and the company name is still submitted. Choose shouldUnregister or disabled when that would be wrong. Hiding an input with CSS keeps it mounted, so its rules still run.
Built-in rules
company uses shouldUnregister: true on the field, so its value is discarded when the user switches to a personal account. vatNumber stays on screen and uses disabled, so it is skipped while it does not apply but keeps what the user typed.
import { useForm, useWatch } from "react-hook-form"type FormValues = {accountType: "personal" | "business"company: stringvatNumber: string}export default function AccountForm({onSubmit,}: {onSubmit: (values: Partial<FormValues>) => void}) {const {control,register,handleSubmit,formState: { errors },} = useForm<FormValues>({defaultValues: { accountType: "personal", company: "", vatNumber: "" },})const accountType = useWatch({ control, name: "accountType" })const isBusiness = accountType === "business"return (<form noValidate onSubmit={handleSubmit(onSubmit)}><fieldset><legend>Account type</legend><label><input type="radio" value="personal" {...register("accountType")} />Personal</label><label><input type="radio" value="business" {...register("accountType")} />Business</label></fieldset>{isBusiness && (<><label htmlFor="company">Company name</label><inputid="company"aria-invalid={!!errors.company}aria-describedby={errors.company ? "company-error" : undefined}{...register("company", {required: "Enter your company name.",// Remove the value when the input is hidden.shouldUnregister: true,})}/>{errors.company && (<p id="company-error" role="alert">{errors.company.message}</p>)}</>)}<label htmlFor="vatNumber">VAT number</label><inputid="vatNumber"{...register("vatNumber", {// Keep the value for later, but skip it while it doesn't apply.disabled: !isBusiness,required: "Enter your VAT number.",})}/>{errors.vatNumber && <p role="alert">{errors.vatNumber.message}</p>}<button type="submit">Continue</button></form>)}
Submitting a personal account gives { accountType: "personal" }, with no company name or VAT number. Because fields can be absent, the submit handler takes Partial<FormValues>. You can also set shouldUnregister: true in useForm to apply it to every field.
Schema validation
A resolver validates the form values, not the mounted inputs. Hidden fields with stale values are still checked, so express the condition in the schema itself. A Zod discriminated union also gives you precise types:
import { useForm, useWatch } from "react-hook-form"import { zodResolver } from "@hookform/resolvers/zod"import { z } from "zod"const schema = z.discriminatedUnion("accountType", [z.object({ accountType: z.literal("personal") }),z.object({accountType: z.literal("business"),company: z.string().trim().min(1, "Enter your company name."),}),])type FormInput = z.input<typeof schema>type FormOutput = z.output<typeof schema>export default function AccountForm({onSubmit,}: {onSubmit: (values: FormOutput) => void}) {const {control,register,handleSubmit,formState: { errors },} = useForm<FormInput, unknown, FormOutput>({resolver: zodResolver(schema),defaultValues: { accountType: "personal" },})const accountType = useWatch({ control, name: "accountType" })return (<form noValidate onSubmit={handleSubmit(onSubmit)}><label htmlFor="accountType">Account type</label><select id="accountType" {...register("accountType")}><option value="personal">Personal</option><option value="business">Business</option></select>{accountType === "business" && (<><label htmlFor="company">Company name</label><input id="company" {...register("company")} />{"company" in errors && errors.company && (<p role="alert">{errors.company.message}</p>)}</>)}<button type="submit">Continue</button></form>)}
When the user switches back to "Personal", the old company name is still in the form values, but the schema's personal branch does not include it. Zod drops unknown keys, so handleSubmit receives exactly { accountType: "personal" }. The submit handler is typed with the union, so TypeScript requires you to check accountType before reading company.
Things to watch
- Use
useWatch, notwatch, in large forms.useWatchre-renders only the component that calls it. See Isolating form subscriptions. - Validate dependent fields together. If one field's rule reads another field, as in "end date after start date", re-validate it when the other changes with
trigger, or use thedepsoption ofregister. - Field arrays. Reordering rows can unmount and remount inputs. Avoid
shouldUnregisterinsideuseFieldArrayrows unless you have tested reordering. - Server validation. The server cannot see which fields were visible. Apply the same condition there, for example with the same schema.