Portal Community

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:

TriggerDebounceNotes
On change (autoValidate)400msAvoids flooding the server on every keystroke
On blurNoneRuns immediately when focus leaves the field
On submitNoneAlways 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

StrategyOn Network ErrorWhen to Use
Fail-open (return valid: true)Allow the user to proceedAvailability checks (username, email) — server validates again on save
Fail-closed (return valid: false)Block the userSafety-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.