Skip to main content

Async validation

The complete asynchronous validation example is compiled and executed with assertions for pending, failure, rerun, and recovery.

Wrap asynchronous validators explicitly with asyncValidator(). This lets the node own pending state, debounce, cancellation, and stale-result handling without executing arbitrary validators to classify them.

import { asyncValidator, field, required } from '@ngblocks/form-nodes';

const myForm = form({
username: field('', [
required,
asyncValidator(async ({ value, abortSignal }) => {
const available = await checkUsername(value(), abortSignal);

return available
? null
: { kind: 'usernameTaken', message: 'This username is already in use.' };
}, { debounce: 300 }),
]),
});

The callback may return a promise, an Observable-like value, a validation result, or a collection of those results.

⏳ Pending and cancellation

While current asynchronous work is running, pending() is true. With no completed error, validationStatus() is unknown and both valid() and invalid() are false. A completed error makes the status invalid even while other validators remain pending.

Each execution receives its own AbortSignal. A newer execution aborts the previous signal, unsubscribes from a previous observable, and ignores stale results even when the underlying work cannot be cancelled.

The validator's debounce delays validation work. This is independent from a field's control-value debounce.

🚨 Conditions and failures

Use when to skip work based on the current context:

const availability = asyncValidator(
async ({ value, abortSignal }) => {
const available = await checkUsername(value(), abortSignal);
return available ? null : { kind: 'usernameTaken' };
},
{
when: ({ value }) => value().length >= 3,
onError: () => ({ kind: 'availabilityUnavailable' }),
},
);

onError converts a rejected promise or observable error into a validation result. Without it, a rejected operation contributes no validation error. Its pending state ends and the rejection is not confused with a domain-validation failure. Use onError when service failure must block the form or produce a visible message.

⚡ Reactive dependencies

Signals read by the callback, when, or options participate in dependency tracking. Changing a dependency schedules a new execution even when the field value is unchanged.

For parameterized validation, separate reactive parameter discovery from untracked asynchronous work:

const tenantId = signal('public');

const usernameAvailable = asyncValidator({
params: () => ({ tenantId: tenantId() }),
debounce: 300,
validate: async ({ value, params, abortSignal }) => {
const available = await api.checkUsername(params.tenantId, value(), abortSignal);
return available ? null : { kind: 'usernameTaken' };
},
});

With explicit params, the library compares the returned result with the previous one before starting another validation. A newly allocated object does not cause another request when all its first-level values remain equal. Changing a first-level value does; nested objects compare by reference rather than recursively. This makes object-literal params convenient while avoiding duplicate work for unrelated signal changes.

The validate callback runs untracked, so put every dependency in params rather than reading signals only after asynchronous work begins. The asyncValidator() reference includes a concrete example of which changes do and do not restart validation.

Without params, signals read before the callback's first asynchronous boundary are discovered as dependencies automatically. Prefer params for reusable validators because it makes the service inputs and restart conditions explicit.

📖 Execution order

  • The node becomes pending synchronously; the first callback begins in the next microtask.
  • Synchronous validation runs first. Async validators do not run while synchronous errors exist.
  • A dependency change cancels stale work and restarts the validator's complete debounce.
  • Synchronous changes to several tracked dependencies are coalesced into one run with their latest values.
  • Promise results are ignored after cancellation even when the service ignores abortSignal.
  • Observable-like results use the first emitted validation result and unsubscribe afterward.
  • Multiple validators run independently, while exposed errors remain in declaration order.

When some async validators have completed with errors and others remain pending, invalid() is already true and pending() remains true. Pending work produces 'unknown' only while no completed error makes the node invalid.

🔌 Lifecycle

Async validation works inside and outside Angular injection contexts. A node's explicit or currently captured injector owns its watcher. Without one, the node temporarily adopts the injector of a directly bound [formNode] host and then uses the nearest ancestor injector by default, including items created later by an array template or factory. Set adoptBindingInjector: false to disable direct binding adoption, or inheritInjector: false to prevent ancestor lookup. Rebinding and detaching release transient ownership without disabling later validation. Outside dependency injection, the library uses weak ownership so unreachable form trees can be garbage-collected.

Disabling, hiding, or marking a node readonly cancels its active async work. Returning it to an interactive state starts validation again against the current committed value.

See Advanced behavior and edge cases for exact dependency, scheduling, ownership, and stale-result semantics. The asyncValidator() reference lists every signature, option, and context member.