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.

A small dependency map Password is a source for confirm password, so changing the password revalidates the confirmation. Country is a source for both postcode and phone, because their formats vary by country. Product is a source for quantity, because the maximum quantity depends on the product's stock. Each arrow means that when the source changes, the dependent's rule is re-run. password → confirm Confirm must equal password. country → postcode, phone Formats vary by country. product → quantity Max depends on stock.

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

  1. Declare dependencies next to the rule. dependsOn: ["password"] on the confirm field makes the relationship visible and machine-readable.
  2. Invert them into a dependents map. On change, you need “who reads this field?”, not “what does this field read?”.
  3. Collect affected fields breadth-first, visiting each once. Chains (country → postcode → delivery estimate) are handled, and a seen set stops loops.
  4. Recompute validity for every affected field. The submit gate and error summary must reflect the current truth, even for fields not yet touched.
  5. 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.
  6. Use your library’s equivalent where it exists. React Hook Form’s deps option on register, 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.

Changing the password in three situations When the confirm field has not been touched, changing the password recomputes confirm's validity to mismatched but displays nothing. When the confirm field is showing passwords do not match and the new password now equals the confirmation, validity becomes valid and the error clears immediately. When confirm was valid and the password changes so they no longer match, validity becomes mismatched; the error is not shown until the user blurs confirm or submits. Confirm field state Validity after change Displayed untouched mismatch (recorded) nothing showing "do not match" match error cleared immediately valid, previously touched mismatch shown on its blur or submit

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.

One change, traced through the revalidator The user changes the country from GB to US. The revalidator collects affected fields breadth first: country itself, then postcode and phone. It recomputes validity for all three. Country is valid and shown as such. Postcode, which the user had filled with a UK postcode and blurred, becomes invalid, but because its error was not showing it is recorded silently until the next blur or submit. Phone was showing an error, so its new result is displayed immediately. country changes GB → US affected: country, postcode, phone Breadth-first, each once. Recompute validity All three re-run. Submit gate and summary now correct. Decide visibility per field Shown before? update live. Never shown? stay silent. No surprise errors on fields the user has not revisited.

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.


Related

← Cross-Field Dependency Logic