The most frustrating form bug to report is “the submit button does nothing”: a field became invalid, another answer then hid it, and its error still blocks submission from a place the user can no longer see or reach.
Conditional fields are the norm in real forms — “Other (please specify)”, a company section that appears for business accounts, a shipping address hidden by “same as billing”. Error state mapping patterns routes errors to visible components; this page decides what happens to an error when its component goes away, which depends on why it went away.
Context and prerequisites
A field disappears for one of three reasons, and each needs a different rule:
- Logically irrelevant — another answer made the question not apply (“same as billing” is ticked, so the shipping address is not asked). The field’s value should not be validated or submitted, so its errors must be removed, not merely hidden.
- Visually collapsed — the field still applies but is out of view: a closed accordion section, a later wizard step, a virtualised row scrolled off screen. Its errors are real and must still block submit — and the summary must be able to reveal it.
- Temporarily unmounted by the framework — a keyed remount, a tab switch that unmounts inactive panels. The field still applies; state must survive the unmount.
The bug in the introduction is case one handled as case two: the field is irrelevant, but its error is kept.
The core pattern: declare relevance, derive everything from it
type Values = Record<string, unknown>;
// Each conditional field declares WHEN it applies, as a pure function of values.
// This is the single source of truth for "is this field part of the form right now".
export const relevance: Record<string, (v: Values) => boolean> = {
shippingAddress: (v) => v.sameAsBilling !== true,
otherReason: (v) => v.reason === "other",
companyName: (v) => v.accountType === "business",
vatNumber: (v) => v.accountType === "business" && v.country !== "US",
};
export const isRelevant = (field: string, v: Values) => relevance[field]?.(v) ?? true;
// Validation only runs for relevant fields; irrelevant fields have NO errors,
// by construction, so a stale one cannot block submit.
export function validateAll(
v: Values,
validators: Record<string, (value: unknown, all: Values) => string | null>,
): Record<string, string> {
const errors: Record<string, string> = {};
for (const [field, check] of Object.entries(validators)) {
if (!isRelevant(field, v)) continue;
const msg = check(v[field], v);
if (msg) errors[field] = msg;
}
return errors;
}
// The payload omits irrelevant fields, so stale hidden values are never sent.
export function toPayload(v: Values): Values {
return Object.fromEntries(Object.entries(v).filter(([k]) => isRelevant(k, v)));
}
Note what this code does not do: it does not erase the hidden field’s value. If the user unticks “same as billing” again, the address they typed earlier comes back. Relevance controls validation and submission; the value itself is kept until the form is reset or submitted.
Step-by-step walkthrough
- Declare relevance next to the field, as a function of values. Do not infer it from whether a component is mounted — mounting is a rendering detail and differs between accordion, wizard and virtual list.
- Validate only relevant fields. Irrelevant fields produce no errors, so the “invisible blocker” cannot exist. This also keeps skipping validation for disabled and hidden fields consistent with the payload.
- Build the payload from relevant fields. Hidden values stay in state for convenience but never reach the server.
- Keep collapsed-but-relevant errors. A closed accordion section with an invalid field still blocks submit, and its error appears in the summary.
- Make the summary able to reveal. Each summary link knows how to open the section or step containing its field before moving focus, as in moving focus to the first invalid field.
- Store errors in form state, not in components. Then an unmounted field’s error survives, and remounting shows it again.
Failure modes and edge cases
1. Using display: none checks as relevance
Reading offsetParent === null to decide whether a field “counts” treats a collapsed accordion as irrelevant — its errors vanish and invalid data is submitted. Relevance must come from values, not layout.
2. Clearing values on hide
Wiping the shipping address when “same as billing” is ticked destroys work if the user unticks it a second later. Keep the value; exclude it from validation and payload. If you must clear (privacy-sensitive fields), clear on submit, not on hide.
3. Relevance chains
vatNumber depends on accountType and country. If country is itself conditional, relevance must be transitive: a field is irrelevant if any field it depends on is irrelevant. Compute relevance in dependency order, as in building a field dependency graph.
4. Schema validation ignores relevance
A single Zod object with vatNumber: z.string().min(1) fails for personal accounts. Model conditions in the schema with a discriminated union — see discriminated unions for conditional schemas — or validate the payload produced by toPayload.
5. Server errors for hidden fields
A 422 naming a field that is now irrelevant cannot be fixed by the user. Map it to a form-level error that explains what to change, rather than attaching it to an invisible input.
Verification checklist
Frequently Asked Questions
Should hidden fields be disabled instead?
Disabling a native control removes it from FormData and from constraint validation, which gives the right result for irrelevant fields rendered but hidden. It does not help with controlled state or schemas, which still see the value. Use relevance as the rule and disabling as one way to implement it in the DOM.
What about fields hidden by feature flags or permissions?
Treat them as irrelevant for this user: not validated, not submitted. If the server requires them regardless, that is a contract bug between client and server, and it should surface as a form-level error rather than a hidden field error.
Is it confusing when an old value reappears after unhiding?
Rarely; it is usually what people expect when they flip an option back. It becomes confusing only after a long gap or a submit. Clear retained hidden values after a successful submit or when the form is reset.