Async Step Validation
Run a server check before advancing to the next step. Pass onStepValidate, get errors back or null to proceed.
When to use it
- Email/username uniqueness ("already registered")
- Promo code or invite code validation
- Address verification
- Any server-side business rule that blocks the next step
Setup
import { FormRenderer } from '@formhaus/react';
async function validateStep(stepId: string, values: Record<string, unknown>) {
if (stepId === 'account') {
const res = await fetch('/api/check-email', {
method: 'POST',
body: JSON.stringify({ email: values.email }),
});
const data = await res.json();
if (data.taken) {
return { email: 'This email is already registered' };
}
}
return null;
}
function SignupForm() {
return (
<FormRenderer
definition={definition}
onStepValidate={validateStep}
onSubmit={handleSubmit}
/>
);
}<script setup>
import { FormRenderer } from '@formhaus/vue';
async function validateStep(stepId, values) {
if (stepId === 'account') {
const res = await fetch('/api/check-email', {
method: 'POST',
body: JSON.stringify({ email: values.email }),
});
const data = await res.json();
if (data.taken) {
return { email: 'This email is already registered' };
}
}
return null;
}
</script>
<template>
<FormRenderer
:definition="definition"
:on-step-validate="validateStep"
@submit="onSubmit"
/>
</template>Error handling
Field errors
Return an object with field keys. Errors show on the fields, same as client-side validation.
return { email: 'Already registered', username: 'Contains banned word' };Errors for hidden or missing fields
If you return an error for a field that doesn't exist or is currently hidden, it shows as a banner above the buttons (same as top-level errors).
return { _rateLimit: 'Too many attempts. Try again in 5 minutes.' };Network errors
If your function throws, stepValidating resets and the error propagates to your code.
// React example: catch and show a toast
async function handleNext() {
try {
await engine.nextStepAsync();
} catch (err) {
toast.error('Connection failed. Check your internet and try again.');
}
}With FormRenderer, errors propagate as unhandled promise rejections. Wrap onStepValidate if you want to handle them:
async function validateStep(stepId, values) {
try {
const res = await fetch('/api/validate-step', { ... });
return await res.json();
} catch {
return { _network: 'Could not reach the server. Try again.' };
}
}Edge cases
Back while validating. The async call finishes but the result is discarded. No stale errors on the wrong step.
Last step. onStepValidate runs on the last step too. Errors block submit.
Type reference
type StepValidateFn = (
stepId: string,
values: Record<string, unknown>,
) => Promise<Record<string, string> | null | void>;The callback receives:
stepId- theidof the step being validated (from your definition)values- all current field values (not just the current step's fields)
Return:
{ key: message }- errors to show. Keys match field keys in your definition.nullorundefined- no errors, advance to next step.
Using the engine directly
If you use FormEngine without the React/Vue adapter, call nextStepAsync() instead of nextStep():
import { FormEngine } from '@formhaus/core';
const engine = new FormEngine(definition, initialValues, {
validators: { ... },
onStepValidate: async (stepId, values) => {
// your server call
},
});
// In your navigation handler:
const advanced = await engine.nextStepAsync();
// Check loading state for your UI:
engine.stepValidating; // true while onStepValidate is runningnextStep() is unchanged and ignores onStepValidate.
Next steps
- Validation: client-side validation rules (runs before async)
- Error Handling: how errors display, loading states
- Multi-Step Forms: step navigation, conditional steps, progress
- Definition Reference: all TypeScript types