Forms, Validation & User Input

Async Validation: Server-Side Checks

15 min Lesson 7 of 12

Async Validation: Server-Side Checks

Client-side validation — checking that a field is not empty or matches a regex — happens instantly and synchronously. But some rules can only be enforced by the server: is this username already taken? Does this coupon code exist? Is this email already registered? These checks require an asynchronous network call, and Flutter's built-in FormField validator is synchronous, so you cannot simply await inside it. This lesson shows the professional pattern for wiring async validation into a Flutter form.

Why the Built-In Validator Cannot Be Async

The validator callback on a TextFormField has the signature String? Function(String?). It returns a String? — not a Future<String?>. Flutter calls it synchronously during _formKey.currentState!.validate(), so any Future returned from it is ignored. Attempting await inside a validator will compile, but the result arrives after the validator already returned null (valid), producing a silent bug.

Warning: Never await inside a TextFormField validator. The framework discards the Future and treats the field as immediately valid. Always perform async checks in a separate method and store the result in state.

The Manual State Pattern

The correct approach manages async validation state manually inside a StatefulWidget:

  • _isChecking — a bool that shows a loading indicator while the API call is in flight.
  • _serverError — a String? that stores the error message returned by the server, or null when the value is valid.
  • _validatedValue — the last value that was checked, used to avoid re-checking the same input on every keystroke.

The submit button reads both _isChecking and _serverError to decide whether to allow submission. The synchronous validator then simply reads _serverError from state and returns it — no async work inside the validator at all.

Username Availability Check — Full Example

import 'package:flutter/material.dart';
import 'dart:async';

class RegistrationForm extends StatefulWidget {
  const RegistrationForm({super.key});

  @override
  State<RegistrationForm> createState() => _RegistrationFormState();
}

class _RegistrationFormState extends State<RegistrationForm> {
  final _formKey = GlobalKey<FormState>();
  final _usernameController = TextEditingController();

  bool _isChecking = false;
  String? _serverError;
  String? _validatedValue;
  Timer? _debounce;

  @override
  void dispose() {
    _usernameController.dispose();
    _debounce?.cancel();
    super.dispose();
  }

  // Called on every keystroke — debounced to avoid hammering the API
  void _onUsernameChanged(String value) {
    _debounce?.cancel();
    // Reset previous result immediately so the UI is not stale
    setState(() {
      _serverError = null;
      _validatedValue = null;
    });

    if (value.trim().length < 3) return; // skip short values

    _debounce = Timer(const Duration(milliseconds: 600), () {
      _checkUsernameAvailability(value.trim());
    });
  }

  // Performs the async API call and stores the result in state
  Future<void> _checkUsernameAvailability(String username) async {
    setState(() => _isChecking = true);

    try {
      // Replace with your real HTTP call, e.g. via Dio or http package
      final available = await _fakeApiCheck(username);
      if (!mounted) return; // guard against disposed widget
      setState(() {
        _serverError = available ? null : 'Username "$username" is already taken.';
        _validatedValue = username;
      });
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _serverError = 'Could not verify username. Please try again.';
        _validatedValue = username;
      });
    } finally {
      if (mounted) setState(() => _isChecking = false);
    }
  }

  // Simulated network call — replace with real implementation
  Future<bool> _fakeApiCheck(String username) async {
    await Future.delayed(const Duration(milliseconds: 800));
    const taken = ['admin', 'flutter', 'dart'];
    return !taken.contains(username.toLowerCase());
  }

  Future<void> _submit() async {
    // Run sync validators first (non-empty, length, etc.)
    if (!_formKey.currentState!.validate()) return;

    // Block submission if an async check is still running
    if (_isChecking) return;

    // Block submission if the last async check found an error
    if (_serverError != null) return;

    // Block submission if the current value was never checked
    final current = _usernameController.text.trim();
    if (current != _validatedValue) return;

    _formKey.currentState!.save();
    // TODO: proceed with registration API call
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Registering as "$current"...')),
    );
  }

  @override
  Widget build(BuildContext context) {
    final bool canSubmit =
        !_isChecking && _serverError == null &&
        _validatedValue == _usernameController.text.trim();

    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          TextFormField(
            controller: _usernameController,
            decoration: InputDecoration(
              labelText: 'Username',
              suffixIcon: _isChecking
                  ? const SizedBox(
                      width: 20,
                      height: 20,
                      child: Padding(
                        padding: EdgeInsets.all(12.0),
                        child: CircularProgressIndicator(strokeWidth: 2),
                      ),
                    )
                  : _serverError == null && _validatedValue != null
                      ? const Icon(Icons.check_circle, color: Colors.green)
                      : null,
              errorText: _serverError,
            ),
            onChanged: _onUsernameChanged,
            validator: (value) {
              if (value == null || value.trim().isEmpty) {
                return 'Username is required.';
              }
              if (value.trim().length < 3) {
                return 'Username must be at least 3 characters.';
              }
              // The async result is surfaced via errorText above;
              // returning null here lets the Form consider this field clean.
              return null;
            },
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: canSubmit ? _submit : null,
            child: const Text('Register'),
          ),
        ],
      ),
    );
  }
}

Key Design Decisions Explained

  • Debouncing with Timer: The API is only called 600 ms after the user stops typing, preventing a flood of requests on every keystroke.
  • The mounted guard: After every await, check if (!mounted) return before calling setState. Without this, calling setState on a disposed widget throws a runtime exception.
  • Two sources of error display: The TextFormField.decoration.errorText shows the server error in real time. The synchronous validator still enforces local rules (empty check, length). Both are required.
  • Disabling the submit button: Rather than showing an alert dialog, disable the button while _isChecking == true or _serverError != null. This is the most accessible and least disruptive UX pattern.

Handling Edge Cases

Preventing Stale Results After Fast Typing

// Store the username that was sent to the API
String? _pendingCheck;

Future<void> _checkUsernameAvailability(String username) async {
  _pendingCheck = username;
  setState(() => _isChecking = true);

  try {
    final available = await _fakeApiCheck(username);
    if (!mounted) return;

    // Only apply the result if it matches the LATEST request
    if (_pendingCheck != username) return; // superseded by a newer call

    setState(() {
      _serverError = available ? null : 'Username "$username" is already taken.';
      _validatedValue = username;
    });
  } finally {
    if (mounted && _pendingCheck == username) {
      setState(() => _isChecking = false);
    }
  }
}
Tip: For production apps, use the http or dio package and wrap the async check in a CancelToken (Dio) or an http.Client that you close on dispose. This ensures in-flight HTTP requests are cleanly aborted when the widget is removed from the tree.

Summary

Async validation in Flutter requires a deliberate manual pattern because the built-in validator is synchronous. The key takeaways are:

  • Store async results in setState — never await inside the validator callback.
  • Debounce keystrokes to reduce API load.
  • Always check mounted after every await before calling setState.
  • Disable the submit button while a check is in flight or when an error is present.
  • Use errorText on the InputDecoration to show real-time server errors alongside the standard validator for local rules.
Key Takeaway: Flutter's FormField.validator is synchronous by design. Async server checks must be performed outside the validator, stored in widget state, and then consulted by the validator and the submit handler to gate form submission.