Multi-field forms break down in predictable ways: a “confirm password” field does not re-validate when the original password changes, a shipping address silently ignores a “same as billing” toggle, or an async uniqueness check overwrites the current result with a stale response from a previous keystroke. These are not edge cases — they are the default outcome when validation rules are added field-by-field without a shared coordination layer.
This page covers the directed acyclic graph (DAG) approach to cross-field validation, including how to wire it into the broader Validation Logic & Schema Integration pipeline, handle async race conditions, activate rules conditionally by user role, and tear down cleanly on unmount.
Problem Statement
Cross-field dependency logic addresses one specific failure: a change to field A must deterministically trigger re-validation of every field that depends on A, in topological order, without re-running validators for unrelated fields.
The pattern applies whenever:
- One field’s valid set of values depends on another field’s current value (confirm password, date ranges, conditional required flags).
- An async lookup result must be invalidated when an upstream field changes (username uniqueness where the domain is determined by a prior field).
- Validation rules are toggled at runtime by user role, workflow stage, or feature flag — not just by field value.
Without a coordination layer, each field’s onChange handler becomes an implicit dependency on global state, which leads to stale validation results and unpredictable render order.
State Machine Specification
The validator for each dependent field moves through six explicit states. Understanding these transitions is the prerequisite for implementing asynchronous validation strategies that sit on top of this graph.
| State | Meaning | Key trigger |
|---|---|---|
IDLE |
No evaluation in progress | Initial mount or upstream field reset |
PENDING |
Awaiting sequence ID assignment | FIELD_VALUE_CHANGE on an upstream node |
VALIDATING |
Evaluation running with an active sequence | Sequence confirmed, abort controller created |
VALID |
Most recent evaluation passed | Promise resolved within active sequence |
INVALID |
Most recent evaluation failed | Rule returned an error shape |
RETRYABLE |
Network or timeout failure | PROMISE_RESOLUTION_TIMEOUT or fetch error |
Core Implementation
The CrossFieldValidator class below is production-ready TypeScript. It manages the DAG, topological ordering, per-field AbortController instances, and monotonically increasing sequence IDs that prevent stale async results from overwriting current state.
type ValidationState = "idle" | "pending" | "validating" | "valid" | "invalid" | "retryable";
interface ValidationError {
code: string;
message: string;
field: string;
}
interface DependencyNode {
id: string;
/** IDs of fields that must be evaluated before this one */
dependsOn: string[];
evaluate: (
values: Record<string, unknown>,
signal: AbortSignal // always wire the signal into fetch/XHR calls
) => Promise<ValidationError | null>;
}
class CrossFieldValidator {
private graph = new Map<string, DependencyNode>();
/** Monotonically increasing counter — shared across all fields */
private globalSeq = 0;
/** Per-field sequence at the time the last evaluation was dispatched */
private fieldSeq = new Map<string, number>();
/** One AbortController per field — replaced on every new evaluation */
private controllers = new Map<string, AbortController>();
private states = new Map<string, ValidationState>();
private errors = new Map<string, ValidationError | null>();
register(node: DependencyNode): void {
this.graph.set(node.id, node);
this.states.set(node.id, "idle");
this.errors.set(node.id, null);
}
/** Returns IDs in safe evaluation order; throws if a cycle is detected */
private topologicalOrder(): string[] {
const visited = new Set<string>();
const stack = new Set<string>();
const result: string[] = [];
const visit = (id: string): void => {
if (stack.has(id)) throw new Error(`Cycle detected at field "${id}"`);
if (visited.has(id)) return;
stack.add(id);
for (const dep of this.graph.get(id)?.dependsOn ?? []) visit(dep);
stack.delete(id);
visited.add(id);
result.push(id);
};
for (const id of this.graph.keys()) visit(id);
return result;
}
/**
* Re-evaluate all fields that depend (directly or transitively) on changedFieldId.
* Callers await this; the returned map contains the final error per affected field.
*/
async resolveDownstream(
changedFieldId: string,
currentValues: Record<string, unknown>
): Promise<Map<string, ValidationError | null>> {
const order = this.topologicalOrder();
const affected = order.filter((id) =>
this.graph.get(id)?.dependsOn.includes(changedFieldId) || id === changedFieldId
);
const results = new Map<string, ValidationError | null>();
for (const fieldId of affected) {
const node = this.graph.get(fieldId)!;
// Cancel any in-flight evaluation for this field
this.controllers.get(fieldId)?.abort();
const controller = new AbortController();
this.controllers.set(fieldId, controller); // AbortController per field per cycle
const seq = ++this.globalSeq;
this.fieldSeq.set(fieldId, seq);
this.states.set(fieldId, "validating");
try {
const error = await node.evaluate(currentValues, controller.signal);
// Discard result if this evaluation was superseded
if (this.fieldSeq.get(fieldId) !== seq || controller.signal.aborted) {
this.states.set(fieldId, "idle");
continue;
}
this.errors.set(fieldId, error);
this.states.set(fieldId, error ? "invalid" : "valid");
results.set(fieldId, error);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Legitimately superseded — caller will see idle, not an error
this.states.set(fieldId, "idle");
continue;
}
// Network failure or timeout — mark retryable rather than invalid
this.states.set(fieldId, "retryable");
results.set(fieldId, {
code: "NETWORK_ERROR",
message: err instanceof Error ? err.message : "Unknown error",
field: fieldId,
});
} finally {
// Only delete if this is still the active controller (WeakMap alternative pattern)
if (this.controllers.get(fieldId) === controller) {
this.controllers.delete(fieldId);
}
}
}
return results;
}
getState(fieldId: string): ValidationState {
return this.states.get(fieldId) ?? "idle";
}
getError(fieldId: string): ValidationError | null {
return this.errors.get(fieldId) ?? null;
}
/** Call on form unmount — aborts all in-flight requests and clears the graph */
destroy(): void {
// AbortController.abort() is idempotent — safe to call even if already resolved
this.controllers.forEach((c) => c.abort());
this.controllers.clear();
this.graph.clear();
this.states.clear();
this.errors.clear();
this.fieldSeq.clear();
}
}
Integration Guidance
This validator sits at the coordination layer between raw DOM events and the parent Validation Logic & Schema Integration pipeline. Wire it up at the form level, not inside individual field components:
// Instantiate once per form, not per field
const validator = new CrossFieldValidator();
validator.register({
id: "confirmPassword",
dependsOn: ["password"],
evaluate: async (values, _signal) => {
// Synchronous rules still return a Promise for a uniform API
if (values.password !== values.confirmPassword) {
return { code: "MISMATCH", message: "Passwords do not match", field: "confirmPassword" };
}
return null;
},
});
validator.register({
id: "username",
dependsOn: [], // top-level node — no upstream dependencies
evaluate: async (values, signal) => {
const res = await fetch(`/api/check-username?q=${values.username}`, { signal });
if (!res.ok) throw new Error("Network error");
const { taken } = await res.json();
return taken
? { code: "USERNAME_TAKEN", message: "Username is already taken", field: "username" }
: null;
},
});
// In your onChange handler:
async function onPasswordChange(values: Record<string, unknown>) {
const errors = await validator.resolveDownstream("password", values);
// Merge errors into your form state — works with any state manager
dispatch({ type: "SET_FIELD_ERRORS", payload: Object.fromEntries(errors) });
}
// On form unmount:
validator.destroy();
For Zod schema integration, the evaluate function wraps a Zod safeParse call and maps the returned ZodError issues to the ValidationError shape above. This keeps Zod as the rule source-of-truth while the DAG controls evaluation order.
Synchronous validation patterns (keystroke-level checks like required and min-length) run independently of this graph. Wire them to the onChange event before calling resolveDownstream, so immediate feedback arrives without waiting for async evaluations.
Role-Based and Contextual Rule Activation
Enterprise forms toggle validation requirements based on user permissions or workflow stage. Instead of hardcoding conditional branches inside field components, expose a PolicyResolver that the graph queries before running evaluations:
interface PolicyResolver {
isRuleActive(ruleId: string): boolean;
}
// Modified DependencyNode — evaluate can short-circuit based on policy
validator.register({
id: "vatNumber",
dependsOn: ["country", "accountType"],
evaluate: async (values, _signal) => {
// Skip the rule if the current workflow stage does not require it
if (!policy.isRuleActive("vat-required")) return null;
if (!values.vatNumber && values.accountType === "business") {
return { code: "VAT_REQUIRED", message: "VAT number is required for business accounts", field: "vatNumber" };
}
return null;
},
});
When the user role or workflow stage changes, emit a DEPENDENCY_GRAPH_REBUILD event and re-evaluate all currently-dirty fields. Do not mutate the graph itself — only update what the resolver returns. This keeps the graph shape stable and prevents stale cached results.
Edge Cases and Failure Modes
Conditional field removal from the DOM. When a field exits the DOM (a conditional step, an accordion collapse), its node remains registered. Mark it inactive by setting a flag on the node rather than deleting it from the graph. Deletion breaks topological ordering if other nodes still declare the removed node as a dependency. On re-entry, reset the node to idle and re-evaluate.
Hydration mismatches in SSR frameworks. Server-rendered HTML may reflect initial values while the client-side graph has not yet initialized. If the server pre-validates cross-field rules, the client graph’s first resolveDownstream call can flip a field from valid to invalid in a flash. Suppress the first client-side evaluation until the first user interaction by gating resolveDownstream behind an isHydrated flag.
Shadow DOM boundaries. Custom elements that host form controls inside a shadow root do not bubble standard input events. Use composed: true event dispatching or a shared message bus to route value-change notifications to the graph, which lives in the light DOM coordinator.
High-frequency input events. Apply debouncing at the onChange handler level — not inside resolveDownstream. Debouncing inside the evaluator drops intermediate states silently. The handler should collect values and schedule a single resolveDownstream call after the debounce window, preserving all intermediate state transitions.
Cross-browser AbortController behavior. In Safari 15 and below, aborting a fetch that has already settled does not throw — it resolves normally. Always check controller.signal.aborted after await returns, not just inside the catch block.
Troubleshooting Reference
| Failure Scenario | Diagnostic Step | Recovery Action |
|---|---|---|
| Stale validation result overwrites current state | Log this.fieldSeq vs seq at resolution time |
Confirm sequence IDs increment globally, not per-field |
| Cycle detected error on form mount | Print topologicalOrder() node list and trace dependsOn chains |
Remove the circular dependsOn reference; use a shared upstream node |
abort never fires, in-flight requests pile up |
Confirm controllers.get(fieldId)?.abort() is reached before new controller is created |
Check that resolveDownstream is not being awaited at the call site before the abort |
| Hidden field shows stale error after re-show | Check whether DEPENDENCY_GRAPH_REBUILD resets the node’s state to idle |
Explicitly call states.set(fieldId, 'idle') when toggling visibility |
| Network failure marks field invalid rather than retryable | Check catch block — fetch errors and AbortError must be handled separately |
Re-throw after AbortError check; only non-abort errors reach the retryable branch |
Testing and QA Hooks
Add data-field-state attributes to each field wrapper so Playwright and Cypress selectors can assert validation state without coupling to CSS class names:
// React example — apply the same pattern to Vue/Svelte equivalents
function FieldWrapper({ fieldId, validator }: { fieldId: string; validator: CrossFieldValidator }) {
const state = useValidatorState(fieldId, validator);
return (
<div
data-field-id={fieldId}
data-field-state={state} // "idle" | "validating" | "valid" | "invalid" | "retryable"
>
{/* field content */}
</div>
);
}
Playwright assertion:
await expect(page.locator('[data-field-id="confirmPassword"]')).toHaveAttribute(
'data-field-state',
'invalid'
);
For ARIA accessibility regression coverage, wire aria-invalid and aria-describedby off the same state value:
<input
aria-invalid={state === "invalid"}
aria-describedby={state === "invalid" ? `${fieldId}-error` : undefined}
/>
<div
id={`${fieldId}-error`}
role="alert"
aria-live="polite"
>
{error?.message}
</div>
This keeps the ARIA state in sync with the graph state automatically — no separate isError boolean to drift out of sync.
Common Pitfalls
Applying debounce inside resolveDownstream instead of at the call site. This drops intermediate validating state transitions and delays critical feedback. Debounce the trigger; the evaluator should run immediately when called.
Constructing a new graph instance per field component. Each instance maintains its own sequence counter, so cross-instance sequence comparisons are meaningless. Instantiate once at the form root and pass it down via context.
Deleting nodes for conditionally hidden fields. If another node has dependsOn: ["hiddenField"], deleting the node breaks topological sort. Use an active flag instead of deletion.
Not aborting on destroy(). If a component unmounts while an async evaluation is in flight, the resolved callback will attempt to update unmounted state. Calling destroy() in useEffect’s cleanup function is the equivalent of clearing an event listener — it is not optional.
Checking signal.aborted only in the catch block. AbortController does not guarantee a thrown AbortError in all environments and all fetch implementations. Always add if (controller.signal.aborted) return; immediately after each await.
Frequently Asked Questions
How do I prevent circular dependencies in cross-field validation?
Construct the dependency graph as a DAG during initialization. The topologicalOrder() method above uses a grey/white DFS that throws on back-edges. Run it immediately after all nodes are registered and before the first resolveDownstream call. If a cycle is detected, throw a configuration error that names the offending field — this will surface in development before any user sees the form.
Should cross-field validation run on every keystroke?
Synchronous cross-field checks (comparing two local values) can run on input with microtask batching and a shallow equality guard. Asynchronous cross-field evaluations (API calls, expensive transforms) should trigger on blur or after a debounced input window — typically 300–500 ms. Never apply the same debounce interval to both categories; instant local checks should remain instant.
How do I handle validation when a dependent field is conditionally hidden?
Register hidden fields as inactive nodes in the graph. When visibility toggles, emit a DEPENDENCY_GRAPH_REBUILD event that resets the node’s state to idle and optionally clears any cached error. On re-show, the node’s first blur or explicit resolveDownstream call produces a fresh result. Retaining orphaned error messages from hidden fields is one of the most common complaint-generating bugs in multi-step form flows.
Can the same dependency graph handle both synchronous and asynchronous rules?
Yes. Every node’s evaluate function returns a Promise, so synchronous rules resolve immediately without await. The graph traversal is always async-aware, which keeps the calling interface uniform. The practical consequence is that a purely synchronous form incurs zero extra latency — the microtask checkpoint is negligible — while gaining the ability to add async nodes later without changing the graph structure.
Related
- Asynchronous Validation Strategies — debounce patterns, retry logic, and race condition handling for single-field async checks
- Synchronous Validation Patterns — keystroke-level rules that compose with the dependency graph
- Integrating Zod for Schema Validation — mapping Zod refinements and superRefine to the ValidationError shape
- Implementing Async Email Availability Checks — concrete AbortController and debounce implementation