Skip to main content
Some validations can’t run client-side — checking if an email is already registered, verifying a promo code, or validating an address against a geocoding API. FormEngine supports async validators that call your backend and return errors just like synchronous rules.

How async validation works

FormEngine’s validation pipeline is promise-aware. When you define a custom validation function that returns a Promise, FormEngine waits for it to resolve before updating the error state. Synchronous rules still run instantly — async rules run in parallel after sync rules pass.

Writing an async validator

Register a custom validation rule using an async function:
import { ActionDefinition } from '@react-form-builder/core'

const customValidators = {
  uniqueEmail: ActionDefinition.functionalAction(async (e) => {
    const email = e.data['email']
    if (!email) return  // let 'required' handle empty

    const response = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`)
    const { exists } = await response.json()

    if (exists) {
      return 'This email is already registered'
    }
  }),
}
Wire it in the form JSON:
{
  "key": "email",
  "type": "RsInput",
  "props": { "label": { "value": "Email" } },
  "schema": {
    "validations": [
      { "key": "required" },
      { "key": "email" },
      { "key": "uniqueEmail" }
    ]
  }
}
Rules execute in order: requiredemail (sync) → uniqueEmail (async). If a sync rule fails, the async rule doesn’t fire — saving an unnecessary API call.

Debouncing API calls

Without debouncing, every keystroke triggers a server request. Use a debounce wrapper to wait until the user stops typing:
import { debounce } from 'lodash'

const checkEmail = debounce(async (email: string): Promise<string | undefined> => {
  const res = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`)
  const { exists } = await res.json()
  return exists ? 'This email is already registered' : undefined
}, 500)

const customValidators = {
  uniqueEmail: ActionDefinition.functionalAction(async (e) => {
    const email = e.data['email']
    if (!email) return
    return await checkEmail(email)
  }),
}

Showing loading state during validation

While the async validator runs, indicate to the user that checking is in progress. You can track this at the app level:
const [validating, setValidating] = useState(false)

const customValidators = {
  uniqueEmail: ActionDefinition.functionalAction(async (e) => {
    setValidating(true)
    try {
      const res = await fetch(`/api/check-email?email=${encodeURIComponent(e.data['email'])}`)
      const { exists } = await res.json()
      return exists ? 'This email is already registered' : undefined
    } finally {
      setValidating(false)
    }
  }),
}
Then render an indicator near the field:
{validating && <span>Checking availability…</span>}

Error handling

If the API call fails (network error, timeout), decide whether to block submission or allow it:
const customValidators = {
  uniqueEmail: ActionDefinition.functionalAction(async (e) => {
    try {
      const res = await fetch(`/api/check-email?email=${encodeURIComponent(e.data['email'])}`)
      if (!res.ok) return undefined  // allow submission on server error
      const { exists } = await res.json()
      return exists ? 'Already registered' : undefined
    } catch {
      return undefined  // network failure → don't block the user
    }
  }),
}
This is a UX decision — for critical validations (payment, identity), you may want to block and show an error instead.

Async validation on submit

You can also run async validation only on form submit rather than on every field change. Use getValidationResult() in the submit handler:
const handleSubmit = async () => {
  const formData = viewerRef.current?.formData as IFormData
  const validation = await formData.getValidationResult()
  if (validation && Object.keys(validation).length > 0) {
    console.log('Validation failed:', validation)
    return
  }
  await postToServer(formData.data)
}
getValidationResult() runs all validators, including async ones, and returns only when all resolve.

Common async validation patterns

Email uniquenessGET /api/check-email?email=...{ exists: boolean } Promo / coupon codeGET /api/validate-code?code=...{ valid: boolean, discount: number } Address verificationPOST /api/verify-address{ valid: boolean, suggested: string } Username availabilityGET /api/check-username?username=...{ available: boolean }
Last modified on April 22, 2026