---
title: Async validation
description: Validate input values against a server or any other asynchronous source.
---
> ## Documentation Index
> Fetch the complete documentation index at: https://docs.page/netglade/glade_forms/llms.txt
> Use this file to discover all available pages before exploring further.

Asynchronous validators run next to synchronous ones. A typical case is checking whether a username is available.

```dart
username = GladeStringInput(
  inputKey: 'username',
  validator: (v) => (v
        ..minLength(length: 3)
        ..satisfyAsync(
          (value) => api.isUsernameAvailable(value),
          key: 'username-taken',
          devMessage: (value) => 'Username "$value" is already taken',
        ))
      .build(),
);
```

Two async parts are available on the validator:

- `satisfyAsync(predicate)` - asynchronous predicate. When it returns `false`, a `ValueSatisfyPredicateError` is reported.
- `customAsync((value, key) async => ...)` - returns a `GladeValidatorResult` or `null` when the value is valid.

Both accept the same `key`, `severity`, `shouldValidate` options as their synchronous counterparts, plus:

- `runOnlyWhenSyncValid` (default `true`) - the part runs only when synchronous validation produced no error. There is no point asking a server about an empty email.
- `onError` - handles exceptions thrown by the part (and by its `shouldValidate`). Return a result to report it, or `null` to treat the value as valid. Without `onError`, and when `onError` itself throws, an `AsyncValidationFailedError` with key `GladeValidationsKeys.asyncValidationFailed` is reported. Its default message is generic on purpose; the exception stays in `AsyncValidationFailedError.error`.

<Warning>
`onError: (value, error, stackTrace, key) => null` means "this value is valid". It is not a way to ignore a network failure quietly - it makes the form submittable with an unverified value. To swallow the failure without claiming the value is good, keep the default handling and translate `asyncValidationFailed` into a retry message instead.
</Warning>

An `AsyncValidationFailedError` is always an error, even when the part was declared with `severity: warning`. The part's severity describes the value; a failed request describes the infrastructure, and those are different things.

Async parts run sequentially in declaration order and respect `stopOnFirstError` and `stopOnFirstErrorOrWarning` among themselves.
A part gated by `runOnlyWhenSyncValid` (the default) is skipped when synchronous validation produced an error or already stopped. A part declared with `runOnlyWhenSyncValid: false` runs and reports its result even then - the per-part opt-out wins over the stop flags.

## How it fits Flutter forms

`TextFormField.validator` is synchronous, so async validation is split into two halves:

1. **Running** happens in the background. Validation requests (value change, `input.validate()`, `textFormFieldInputValidator`, `formFieldValidator`, `validateAsync()`) schedule a run. Rapid changes are merged by a debounce. The results of asynchronous parts are cached for the value they were produced for.
2. **Reading** stays synchronous. `textFormFieldInputValidator`, `validatorResult`, `isValid` recompute the synchronous half and merge it with the cached asynchronous results.

Only asynchronous results are cached. The synchronous half is recomputed on every read, so a synchronous validator reading another input reacts to that dependency immediately, exactly as it does on an input without async validation.

When a run finishes the model notifies its listeners, the form rebuilds and `TextFormField` re-runs the validator, which now sees the cached result. No special widget is needed:

```dart
TextFormField(
  controller: model.username.controller,
  validator: model.username.textFormFieldInputValidator,
  decoration: InputDecoration(
    labelText: 'Username',
    suffixIcon: model.username.isAsyncValidationRunning ? const CircularProgressIndicator() : null,
  ),
)
```

Two flags describe the wait, so an indicator does not flicker on every keystroke:

- `isValidating` - the debounce is running **or** a request is in flight.
- `isAsyncValidationRunning` - a request is in flight. Use this one for a loading indicator.

`validatorResult.asyncState` carries the same information as `AsyncValidationState.debouncing`, `.running`, `.done` and `.notRun`.

`isValidating`, `isAsyncValidationRunning` and `validateAsync()` are available on `GladeModel` and on `GladeComposedModel`, which aggregates its own inputs together with the models it contains.

Getters such as `isValid`, `validatorResult` or `validationErrors` never start a request. Only validation requests do.
That is why the form's `autovalidateMode` decides when the initial value is validated:

| autovalidateMode | Async validation of the initial value runs |
|---|---|
| `.always` | on the first build |
| `.onUserInteraction` | after the first change |
| `.disabled` | on `formKey.currentState.validate()` or on value change |

Widgets without `FormField` (dropdowns, pickers) go through `model.updateInput` or `input.updateValue`, which trigger async validation as well. Read the message from `input.errorFormatted()`.

## Pending state and `AsyncValidationMode`

While a run is scheduled or in flight, `input.isValidating` and `model.isValidating` are `true`.
What `isValid` returns during that time is decided per model:

```dart
class MyModel extends GladeModel {
  @override
  AsyncValidationMode get asyncValidationMode => AsyncValidationMode.lastKnown;
}
```

| Scenario | `strict` (default) | `lastKnown` |
|---|---|---|
| Async never ran for the current value | synchronous result | synchronous result |
| Synchronous validation failed | `false` (async is skipped) | `false` |
| Value changed, request scheduled or in flight | `false` | synchronous result |
| Async finished with an error | `false` | `false` |
| Async finished, valid | `true` | `true` |
| Async threw (default `onError`) | `false` | `false` |

The modes differ in one situation only: the current value has not been verified yet and synchronous validation passed.
`strict` blocks a submit button bound to `model.isValid` for validation which was actually **triggered**. It cannot block what was never run: a value whose async validation never started (`AsyncValidationState.notRun`) counts as valid in both modes. That happens when synchronous validation failed and later became valid through a dependency without the value changing, or on a form which never triggers validation at all (`autovalidateMode: .disabled`).

Awaiting the server before saving is therefore the robust pattern in **both** modes, not only in `lastKnown`:

```dart
onPressed: () async {
  final isValid = await model.validateAsync();
  if (isValid) save();
}
```

`model.validateAsync()` runs scheduled validations immediately (skipping the debounce), joins requests already in flight, reuses finished results, retries failed ones and returns `isValid` after everything completed. It works in both modes.

A response that arrives for a value the user already changed is discarded. Every finished result exposes `validatorResult.asyncValidatedValue`, the value it was produced for.
A caller already awaiting `input.validateAsync()` still receives that discarded result, so compare `asyncValidatedValue` with `input.value` when you use the returned result directly.

Cached results are dropped whenever the value changes structurally.

Where the text field is the source of the value, the text decides instead: a controller notification which leaves the text unchanged (a cursor move, for example) is not a value change, and `textFormFieldInputValidator` recognises the field's own text as the current value. That keeps a type whose instances never compare equal usable - the conversion produces a new instance each time, but the text does not change. Such a type driven from somewhere else than a text field counts as changed on every assignment, which means a request per assignment; give it a meaningful `==`.

A value mutated **in place** and assigned back is the same instance, so the change cannot be detected; call `validateAsync(force: true)` after such a mutation.

They are also dropped when the set of asynchronous parts which apply changes - a result produced while synchronous validation was failing only covers the parts declared with `runOnlyWhenSyncValid: false`, and is not reused once synchronous validation passes.

`validateAsync()` throws only when synchronous validation throws. Exceptions from an async part or from its `shouldValidate` are turned into validation results instead.

## Debounce

The debounce is configured per validator in `build()`:

```dart
validator: (v) => (v..satisfyAsync(api.isUsernameAvailable)).build(asyncDebounce: const Duration(milliseconds: 500)),
```

Default is 300 ms. `Duration.zero` starts the request immediately. `validateAsync()` always skips the debounce.

## Failed requests

When a part throws, the failure is reported like any other validation result so the user sees it, but it is not treated as a final answer:

- Passive triggers (a rebuild, `validate()`, a form field validator) do **not** retry. A failing server would otherwise be hammered once per frame.
- `input.validateAsync()` and `model.validateAsync()` retry it. Submitting the form is therefore the natural retry, without `force: true`.

## Dependencies

An async validator may read other inputs. When such an input changes, the value of the validated input stays the same, so its cached asynchronous result would be kept. (The synchronous half needs nothing - it is recomputed on every read.)
Re-run the asynchronous half explicitly:

```dart
email = GladeStringInput(
  dependencies: () => [organisation],
  onDependencyChange: (_) => unawaited(email.validateAsync(force: true)),
  validator: (v) => (v
        ..customAsync((value, key) async {
          final allowed = await api.isEmailAllowed(value, organisation.value);
          return allowed ? null : ValueError(value: value, key: key, devMessage: (_) => 'Email not allowed');
        }))
      .build(),
);
```

`force: true` drops the cached result and any request in flight before running.

## Translations

Async results go through the same translation pipeline as synchronous ones, identified by their `key`.
Failures produced by the default `onError` use `GladeValidationsKeys.asyncValidationFailed`; the exception is available as `AsyncValidationFailedError.error`.
`DefaultValidationTranslations.defaultAsyncValidationFailedMessage` sets a fallback message for them.

## Lifecycle notes

- `resetToInitialValue()` goes through the normal value change: a request in flight for the discarded value is dropped and the initial value is validated again. Resetting a value which is already the initial one changes nothing, so its cached result is kept.
- `setNewInitialValue()` does not touch asynchronous state on its own - async results describe the value, not the initial value.
- `dispose()` discards requests in flight; late responses are ignored.
- `GladeComposedModel.isValidating` is `true` when any of its own inputs or any contained model is validating; `validateAsync()` awaits both.
