Synchronous Validation
Synchronous Validation
Every TextFormField (and any widget that accepts a FormField) exposes a validator callback. The callback receives the current field value as a String? and must return a String error message when the value is invalid, or null when it is valid. Flutter displays the returned string as the field's error text and marks the form as invalid. This happens synchronously — the callback runs on the main thread and must finish immediately, with no async I/O.
FormState.validate() is called explicitly, or automatically on every change when autovalidateMode is set to AutovalidateMode.onUserInteraction. The default mode is disabled, so validation only fires when you call validate().The Validator Signature
The type of the validator parameter on TextFormField is:
String? Function(String? value)?
The value is nullable because a field may be empty. Your validator must handle the null case explicitly if the field can be empty. Return a non-null string to signal an error; return null to signal success.
Required-Field Check
The simplest validator ensures a field is not empty. Trim whitespace first so that a value consisting only of spaces is treated as empty:
TextFormField(
decoration: const InputDecoration(labelText: 'Full Name'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Full name is required';
}
return null; // valid
},
)
Calling value.trim().isEmpty instead of value.isEmpty prevents a string like ' ' from passing validation. Always trim before an emptiness check.
Length Constraints
Many fields have minimum or maximum character requirements. Chain length checks after the required check so that the error messages remain distinct and actionable:
TextFormField(
decoration: const InputDecoration(labelText: 'Username'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Username is required';
}
final trimmed = value.trim();
if (trimmed.length < 3) {
return 'Username must be at least 3 characters';
}
if (trimmed.length > 20) {
return 'Username cannot exceed 20 characters';
}
return null;
},
)
trim() multiple times. This keeps the logic clean and avoids off-by-one mistakes from comparing different representations of the same input.Regex-Based Rules
For structured formats such as email addresses, phone numbers, or postal codes, use RegExp to validate the pattern. Dart's RegExp.hasMatch() returns a boolean indicating whether the pattern matches anywhere in the string. Use anchors (^ and $) to ensure the entire string matches, not just a substring:
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
// RFC-5321-compatible simplified pattern
final emailRegex = RegExp(
r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$',
);
if (!emailRegex.hasMatch(value.trim())) {
return 'Enter a valid email address';
}
return null;
},
)
The same technique applies to phone numbers, ZIP codes, and any other pattern-constrained field. Define the RegExp as a top-level or static constant rather than constructing it inside the callback on every keystroke — RegExp compilation is not free.
Combining Multiple Rules
A single validator may enforce several rules in priority order. Return the first error encountered so that the user sees one actionable message at a time:
// Reusable static RegExp — compiled once at class load time
static final _passwordRegex = RegExp(
r'^(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#\$%^&*]).{8,}$',
);
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
if (!_passwordRegex.hasMatch(value)) {
return 'Password must contain an uppercase letter, a digit, and a special character';
}
return null;
}
// In the widget tree:
TextFormField(
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
validator: _validatePassword,
)
validator callback must return synchronously. For checks that require I/O (e.g. "is this username taken?"), use a separate async call triggered by onChanged or a dedicated "Check Availability" button, then store the result in state and reference it from the synchronous validator.Triggering Validation
Wrap your fields in a Form widget and hold a GlobalKey<FormState>. Call _formKey.currentState!.validate() to run all validators at once. It returns true if every validator returned null:
final _formKey = GlobalKey<FormState>();
void _submit() {
if (_formKey.currentState!.validate()) {
// All validators returned null — form is valid
_formKey.currentState!.save();
// proceed with form data
}
}
// In the build method:
Form(
key: _formKey,
child: Column(
children: [
TextFormField(validator: _validateEmail, ...),
TextFormField(validator: _validatePassword, ...),
ElevatedButton(
onPressed: _submit,
child: const Text('Submit'),
),
],
),
)
Summary
Synchronous validators are the primary tool for client-side form validation in Flutter. Write a String? Function(String?) callback, return a descriptive error string for any invalid condition, and return null for valid input. Chain required-field checks, length constraints, and regex rules in that order. Keep validators pure and synchronous — any async work belongs outside the validator callback. Attach validators via TextFormField.validator, and trigger them by calling FormState.validate().