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: required → email (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 uniqueness — GET /api/check-email?email=... → { exists: boolean }
Promo / coupon code — GET /api/validate-code?code=... → { valid: boolean, discount: number }
Address verification — POST /api/verify-address → { valid: boolean, suggested: string }
Username availability — GET /api/check-username?username=... → { available: boolean }
Last modified on April 22, 2026