Validation

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.

username_form.dartidle
Source2 files
username_form_controller.dart
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

ParameterDefaultWhat it does
validatorrequiredFuture<E?> Function(T). Returning null means valid. Called only after the sync validator passed, and only when a round starts
debounce300 msHow long to wait after a value change before calling validator. Each new value restarts the wait. validate() ignores it and flushes instead
timeoutnoneHow long validator may run before the round is abandoned as a failure. Without it, a validator that never settles hangs await validate()
onFailurereports to FlutterErrorCalled when the validator throws or times out. Invoked after the state resolved, so a slow handler cannot hold the field in validating
failureToErrornoneTurns 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

On this page