Async validation
Server-side checks with debounce, cancellation and cached verdicts — one AsyncValidation per field.
Some checks live on the server — "is this username taken?". Pass an AsyncValidation, which every field controller
accepts, and the field runs it after its sync validator passed. Try alice, then boom.
Source2 files
enum UsernameError { required, tooShort, taken, checkFailed }
class UsernameFormController extends AdvancedFormController {
UsernameFormController()
: super(validationMode: ValidationMode.onUserInteraction) {
registerFields([username]);
}
late final username = AdvancedTextFieldController(
validator:
filled(UsernameError.required) &
atLeastLength(3, UsernameError.tooShort),
asyncValidation: AsyncValidation(
validator: _isAvailable, // Future<UsernameError?> Function(String)
debounce: const Duration(milliseconds: 400), // default 300 ms
timeout: const Duration(seconds: 3), // default: no bound
failureToError: (error, stackTrace) => UsernameError.checkFailed,
),
);
Future<UsernameError?> _isAvailable(String value) async {
await Future<void>.delayed(const Duration(milliseconds: 700));
if (value == 'boom') {
throw Exception('the directory service is down');
}
const taken = {'alice', 'bob', 'admin'};
return taken.contains(value) ? UsernameError.taken : null;
}
}DocsFailureBanner, DocsFieldStatus, DocsHint, DocsSubmitButton and DocsTextField are shorthands these docs define, not part of the package. See Rendering fields for the widget code an app writes.The parameters
| Parameter | Default | What it does |
|---|---|---|
validator | required | Future<E?> Function(T). Returning null means valid. Called only after the sync validator passed, and only when a round starts |
debounce | 300 ms | How long to wait after a value change before calling validator. Each new value restarts the wait. validate() ignores it and flushes instead |
timeout | none | How long validator may run before the round is abandoned as a failure. Without it, a validator that never settles hangs await validate() |
onFailure | reports to FlutterError | Called when the validator throws or times out. Invoked after the state resolved, so a slow handler cannot hold the field in validating |
failureToError | none | Turns a failure into an error code the field can show. Without it a failed field carries no code |
What you get for free
Debounced while typing, immediate on submit. Under onUserInteraction every settled keystroke starts one
debounced round. await validate() runs a round still waiting out its debounce now, awaits a round already in
flight instead of starting a second one, and never reports the field bad for being busy.
A stale answer can never land. A round belongs to one value. Changing the value — or calling setError,
clearErrors, reset, markReadOnly, setValidationEnabled(false) or dispose — kills the live round, and its
later result is dropped.
Verdicts are reused. A settled answer is kept while it still describes the value, so a second submit press on an
unchanged form makes no network calls. Any edit invalidates that field's verdict. If the check depends on state
outside the value — a different tenant, a changed date — invalidate it yourself with field.clearErrors().
Renderable status. The status walks pending → validating → valid or invalid, so a spinner is one check on
state.isInProgress. The error goes blank while a check runs, because both errors described the previous value; to
keep the old text up, render state.error only while !state.isInProgress.
Next
Custom validators
Any function of the right shape is a validator — including one whose error is a list, so a field reports several violations at once.
Async failures and patterns
A validator that throws is a failure, not an error. What the field does with it, and the patterns async checks tend to need.