A “confirm password” error that stays after the user fixes the password, a postcode that stays invalid after the user changes the country, a maximum-quantity error that stays after the product changes — all the same bug: a field’s validity depends on another field, and only the edited field was revalidated.
The fix is to record dependencies explicitly and, when a source changes, revalidate its dependents — carefully. Revalidating an untouched dependent would show errors the user has not earned yet; revalidating everything is wasteful; and dependencies can chain or loop. This page, within cross-field dependency logic, builds a dependency-driven revalidation step that respects the display timing rules from the rest of the form.
Context and prerequisites
Two kinds of dependency show up in forms:
- Validity dependencies — a field’s rule reads another field. Confirm password reads password; postcode format reads country; end date reads start date; quantity maximum reads the selected product’s stock.
- Relevance dependencies — whether a field applies at all depends on another field (VAT number only for businesses). These change which fields are validated, covered in conditional required fields without cycles.
This page handles validity dependencies. The key principle: revalidate dependents, but let each dependent’s own display state decide whether the new result is shown. A dependent the user has not touched gets its validity recomputed (so submit and summaries are correct) but no new visible error.
The core pattern: declared dependencies and touched-aware revalidation
type Values = Record<string, unknown>;
type Validator = (value: unknown, all: Values) => string | null;
export interface FieldDef { validate: Validator; dependsOn?: string[] }
export function createRevalidator(fields: Record<string, FieldDef>) {
// Invert "dependsOn" into "dependents of": source -> fields that read it.
const dependents = new Map<string, Set<string>>();
for (const [name, def] of Object.entries(fields)) {
for (const src of def.dependsOn ?? []) {
if (!dependents.has(src)) dependents.set(src, new Set());
dependents.get(src)!.add(name);
}
}
/**
* Fields to re-run after `changed` changes: the field itself plus every
* transitive dependent, each once, in breadth-first order. The `seen` set
* makes cycles harmless: a field is never visited twice.
*/
function affected(changed: string): string[] {
const order: string[] = [];
const seen = new Set<string>();
const queue = [changed];
while (queue.length) {
const f = queue.shift()!;
if (seen.has(f)) continue;
seen.add(f);
order.push(f);
for (const d of dependents.get(f) ?? []) queue.push(d);
}
return order;
}
return {
onChange(changed: string, values: Values, state: { errors: Record<string, string | null>; shown: Record<string, boolean> }) {
for (const f of affected(changed)) {
const err = fields[f].validate(values[f], values);
state.errors[f] = err; // validity always recomputed
// Visibility: displayed = shown[f] && errors[f].
// - A field already showing an error keeps showing, with the NEW
// message, or clears the moment it becomes valid (reward early).
// - A field not showing an error is left silent; its own blur or the
// next submit decides when to reveal it (punish late).
if (err === null) state.shown[f] = false;
}
return state;
},
affected,
};
}
const revalidate = createRevalidator({
password: { validate: (v) => (String(v ?? "").length >= 12 ? null : "Use at least 12 characters.") },
confirm: {
dependsOn: ["password"],
validate: (v, all) => (v === all.password ? null : "Passwords do not match."),
},
country: { validate: (v) => (v ? null : "Choose a country.") },
postcode: {
dependsOn: ["country"],
validate: (v, all) => postcodeRule(String(all.country))(String(v ?? "")),
},
});
declare function postcodeRule(country: string): (v: string) => string | null;
Step-by-step walkthrough
- Declare dependencies next to the rule.
dependsOn: ["password"]on the confirm field makes the relationship visible and machine-readable. - Invert them into a dependents map. On change, you need “who reads this field?”, not “what does this field read?”.
- Collect affected fields breadth-first, visiting each once. Chains (country → postcode → delivery estimate) are handled, and a
seenset stops loops. - Recompute validity for every affected field. The submit gate and error summary must reflect the current truth, even for fields not yet touched.
- Update visibility only where the error was already shown. If the confirm field was showing “Passwords do not match”, changing the password updates it live — clearing it the moment they match. If the user has not reached the confirm field yet, nothing appears there. This is reward early, punish late applied across fields.
- Use your library’s equivalent where it exists. React Hook Form’s
depsoption onregister, VeeValidate’s cross-field rules with dependent fields, and Angular’s group validators all implement a version of this.
Why validity and visibility are separate here
Merging “is it valid?” and “should the error be shown?” is what makes dependent revalidation go wrong in both directions. If revalidation also shows errors, changing the password flashes “Passwords do not match” on a confirm field the user has not reached yet. If revalidation is skipped for untouched fields to avoid that, the submit gate thinks the form is valid when it is not, and the summary misses a problem. Keeping the two concepts separate — validity always recomputed, visibility governed by each field’s own interaction state — gives correct submit behaviour and polite live feedback at the same time.
Failure modes and edge cases
1. Cascading visible errors
Changing the country re-runs postcode and phone. If both flash errors because their old values do not fit the new country, the user is scolded for something they have not done yet. Keep them silent until touched again or submitted, but mark them invalid so submit catches them.
2. Cycles
Two fields that constrain each other (min and max price) form a cycle. The seen set prevents infinite loops; make sure the rules themselves are symmetrical so the result does not depend on which field changed first.
3. Async dependents
If a dependent’s validator is async (postcode lookup for the new country), revalidation starts a request; cancel the previous one per field, as in cancelling stale async validation with AbortController.
4. Server errors on dependents
A server error on confirm (“passwords do not match” from the server) must be cleared when either password field changes, not only when confirm changes. Apply the same dependency map when clearing server errors — the rules in clearing server errors when a field changes.
5. Large graphs
With hundreds of fields, recomputing transitive dependents on each keystroke is still cheap if the graph is sparse. If it is dense (totals depending on every row), debounce the dependent pass separately from the field’s own validation.
Verification checklist
Frequently Asked Questions
Can I just revalidate the whole form on every change?
For small forms, yes, as long as visibility is still governed per field. It becomes wasteful with large forms or async validators, and it hides the dependency structure that makes rules understandable. A declared map scales better.
How does React Hook Form's deps option compare?
register("confirm", { deps: ["password"] }) triggers validation of confirm when password changes. It follows the library’s own display rules for errors, which you should configure to avoid showing untouched errors; the concept is the same as this page’s dependents map.
Should a dependent's value be cleared when its source changes?
Sometimes: a “city” select whose options depend on the country should reset if the chosen city is no longer an option. That is a value dependency, separate from validation; reset only values that became impossible, and tell the user if you do.