Async Validation: Server-Side Checks
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.
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
boolthat shows a loading indicator while the API call is in flight. - _serverError — a
String?that stores the error message returned by the server, ornullwhen 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
mountedguard: After everyawait, checkif (!mounted) returnbefore callingsetState. Without this, callingsetStateon a disposed widget throws a runtime exception. - Two sources of error display: The
TextFormField.decoration.errorTextshows the server error in real time. The synchronousvalidatorstill enforces local rules (empty check, length). Both are required. - Disabling the submit button: Rather than showing an alert dialog, disable the button while
_isChecking == trueor_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);
}
}
}
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— neverawaitinside thevalidatorcallback. - Debounce keystrokes to reduce API load.
- Always check
mountedafter everyawaitbefore callingsetState. - Disable the submit button while a check is in flight or when an error is present.
- Use
errorTexton theInputDecorationto show real-time server errors alongside the standardvalidatorfor local rules.
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.