Atlas Forms
Async Validation
Async validators perform server-side checks that cannot be evaluated in the browser — username uniqueness, email domain verification, IBAN bank reachability, postcode existence, or coupon code validity. They return a Promise<ValidationResult> and are automatically debounced to minimise API calls.
Writing an Async Validator
import { registerValidator } from '@atlas-forms/validation-js';
// Username availability check
registerValidator('usernameAvailable', async (value, fieldId, allValues) => {
// Guard: skip async check for short values (let minLength rule handle these)
if (!value || String(value).length < 3) {
return { valid: true };
}
try {
const response = await fetch(
`/api/users/check-username?q=${encodeURIComponent(value)}`,
{ credentials: 'same-origin' }
);
if (!response.ok) {
// Network or server error — fail open (don't block the user)
console.warn('Username check failed:', response.status);
return { valid: true };
}
const data = await response.json();
return {
valid: data.available,
message: data.available
? undefined
: `"${value}" is already taken — please choose a different username`
};
} catch (err) {
// Network failure — fail open
console.error('Username availability check error:', err);
return { valid: true };
}
});
Using in Schema
{
"id": "username",
"type": "text",
"label": "Username",
"validation": {
"required": true,
"minLength": 3,
"maxLength": 30,
"pattern": "^[a-zA-Z0-9_\\-]+$",
"patternMessage": "Only letters, numbers, underscores and hyphens",
"customRule": {
"name": "usernameAvailable",
"message": "Username is already taken",
"async": true
}
}
}
Debounce Behaviour
The validation engine applies a 400ms debounce to async validators when triggered on change. On blur and on submit, async validators run immediately without debounce. The timing table:
| Trigger | Debounce | Notes |
|---|---|---|
| On change (autoValidate) | 400ms | Avoids flooding the server on every keystroke |
| On blur | None | Runs immediately when focus leaves the field |
| On submit | None | Always runs — form submission waits for async results |
Loading State UI
While an async validator is pending, the control shows a spinner indicator. Once the result arrives, the spinner is replaced by either the error message (if invalid) or nothing (if valid). This is built into the control renderer automatically.
Coupon Code Validation Example
registerValidator('couponValid', async (value) => {
if (!value) return { valid: true };
const res = await fetch(`/api/coupons/validate?code=${encodeURIComponent(value)}`);
const { valid, discount, reason } = await res.json();
return {
valid,
message: valid ? undefined : reason ?? 'Invalid or expired coupon code'
};
});
// Schema
{
"id": "coupon-code",
"type": "text",
"label": "Coupon Code",
"settings": { "placeholder": "Enter coupon code" },
"validation": {
"customRule": {
"name": "couponValid",
"message": "Invalid coupon code",
"async": true
}
}
}
Fail-Open vs Fail-Closed
| Strategy | On Network Error | When to Use |
|---|---|---|
Fail-open (return valid: true) | Allow the user to proceed | Availability checks (username, email) — server validates again on save |
Fail-closed (return valid: false) | Block the user | Safety-critical checks (payment card, identity verification) |
Always Validate Server-Side Too
Async client-side validation is a UX enhancement — it gives users immediate feedback. It does not replace server-side validation. Your API endpoint must always re-validate the submitted data before persisting it, since client-side checks can be bypassed by a motivated user.