Forms and validation: Flutter best practices explained
Input validation is often where user experience either flows or falters—a missing email check or a silently rejected form submission can destroy trust fast. Building reliable input handling in Flutter means understanding the interplay between form state, validation rules, input constraints, and submission feedback. Let's walk through the patterns that keep forms predictable and resilient in production apps.
Form and TextFormField — Built-in validation with a GlobalKey<FormState>
Flutter's Form widget provides a stateful wrapper around multiple input fields. It doesn't validate on its own—instead, it holds a reference point (GlobalKey<FormState>) that lets you trigger validation, reset fields, and save values across the entire form at once. This is far cleaner than managing individual field states manually.
TextFormField is the input counterpart. It wraps TextField and adds a validator parameter—a callback that runs when you call form.validate(). The validator receives the current field value and returns either null (valid) or an error string to display.
Here's a practical sign-up form:
class SignUpForm extends StatefulWidget {
@override
State<SignUpForm> createState() => _SignUpFormState();
}
class _SignUpFormState extends State<SignUpForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
// Form is valid, proceed with signup
print('Email: ${_emailController.text}');
print('Password: ${_passwordController.text}');
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
if (!value.contains('@')) {
return 'Enter a valid email';
}
return null;
},
),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
},
),
SizedBox(height: 20),
ElevatedButton(
onPressed: _submitForm,
child: Text('Sign Up'),
),
],
),
);
}
}The key pattern: _formKey.validate() triggers all validators in the form. Each TextFormField shows an error message below its input if validation fails. Only when all validators return null does the form proceed.
Validators — Reusable functions returning error messages
Inline validators work for simple cases, but production apps often repeat the same checks across multiple forms. Extract validators into reusable functions. This centralizes logic, makes testing easier, and keeps your form code readable.
Build a validators utility:
class Validators {
static String? email(String? value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
final emailRegex = RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$');
if (!emailRegex.hasMatch(value)) {
return 'Enter a valid email address';
}
return null;
}
static String? password(String? value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
if (!value.contains(RegExp(r'[A-Z]'))) {
return 'Password must contain an uppercase letter';
}
if (!value.contains(RegExp(r'[0-9]'))) {
return 'Password must contain a number';
}
return null;
}
static String? confirmPassword(String? value, String passwordValue) {
if (value != passwordValue) {
return 'Passwords do not match';
}
return null;
}
static String? phone(String? value) {
if (value == null || value.isEmpty) {
return 'Phone is required';
}
final phoneRegex = RegExp(r'^\+?[1-9]\d{1,14}$');
if (!phoneRegex.hasMatch(value.replaceAll(' ', ''))) {
return 'Enter a valid phone number';
}
return null;
}
static String? notEmpty(String? value, String fieldName) {
if (value == null || value.trim().isEmpty) {
return '$fieldName is required';
}
return null;
}
}Now your form stays lean:
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
validator: Validators.email,
),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
validator: Validators.password,
),
TextFormField(
controller: _confirmPasswordController,
decoration: InputDecoration(labelText: 'Confirm Password'),
obscureText: true,
validator: (value) {
return Validators.confirmPassword(value, _passwordController.text);
},
),This approach scales well. As you add more forms across your app—payment, profile, checkout—you reuse the same validators without duplication. It's also simpler to test validators in isolation.
Input formatters — Restrict and format input like phone numbers
InputFormatter instances shape data as the user types, before the validator ever sees it. Use them to enforce character limits, strip unwanted characters, or apply visual formatting. A phone number field that auto-formats to (123) 456-7890 is just formatter magic.
Flutter provides built-in formatters like FilteringTextInputFormatter and LengthLimitingTextInputFormatter. For custom formatting, extend TextInputFormatter:
class PhoneInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text.replaceAll(RegExp(r'\D'), '');
if (text.isEmpty) {
return newValue.copyWith(text: '');
}
final buffer = StringBuffer();
for (int i = 0; i < text.length && i < 10; i++) {
if (i == 3 || i == 6) {
buffer.write(' ');
}
buffer.write(text[i]);
}
final formattedText = buffer.toString();
return newValue.copyWith(
text: formattedText,
selection: TextSelection.collapsed(offset: formattedText.length),
);
}
}Apply it to a phone field:
TextFormField(
decoration: InputDecoration(labelText: 'Phone Number'),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
PhoneInputFormatter(),
],
keyboardType: TextInputType.phone,
validator: Validators.phone,
),Now as users type, they see "123 456 7890" instead of raw digits. The formatter strips non-numeric input and applies the spacing automatically. The validator still runs independently, so you can reject incomplete numbers even if the format looks polished.
Submit states — Disable buttons and show progress while saving
A form isn't truly complete until it handles the submission lifecycle. After validation passes, you'll typically call an API, save to a database, or trigger some async work. During that time, disable the submit button and show feedback—otherwise users click repeatedly or navigate away thinking nothing happened.
Here's a complete form with submit state management:
class SubmitForm extends StatefulWidget {
@override
State<SubmitForm> createState() => _SubmitFormState();
}
class _SubmitFormState extends State<SubmitForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
Future<void> _submitForm() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Simulate API call
await Future.delayed(Duration(seconds: 2));
// Success
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Form submitted successfully')),
);
_formKey.currentState!.reset();
_emailController.clear();
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Submission failed: ${e.toString()}';
});
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
validator: Validators.email,
enabled: !_isLoading,
),
if (_errorMessage != null)
Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red),
),
),
SizedBox(height: 20),
_isLoading
? SizedBox(
height: 36,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
: ElevatedButton(
onPressed: _submitForm,
child: Text('Submit'),
),
],
),
);
}
}Key points here:
- Disable inputs during loading: Set
enabled: !_isLoadingon form fields to prevent accidental edits mid-submission. - Clear error state: Reset
_errorMessagewhen a new submission starts. - Use
mountedcheck: After an async operation, always verify the widget is still in the tree before callingsetState. - Show progress feedback: Replace the button with a spinner, or use a
ButtonStateenum for more complex UX. - Reset on success: Call
_formKey.currentState!.reset()to clear fields and validation errors after successful submission.
Common mistakes
Not disposing TextEditingController: Controllers hold references to listeners and resources. Always call dispose() in your State's dispose method, or use a TextEditingController provider if you're using a state management solution.
Validator logic in onChanged: Avoid triggering validation on every keystroke unless your UX explicitly calls for it. Instead, validate on focus loss or form submission. Constant error messages while typing frustrate users.
Ignoring cross-field dependencies: Password confirmation and other inter-field validations require access to sibling field values. Either pass them as parameters (like we did with confirmPassword) or use a form-level validator that checks multiple fields at once.
Forgetting to check mounted after async: If a user navigates away while a form submission is in flight, calling setState on a disposed widget crashes the app. Always guard setState calls with if (mounted).
Coupling validators to UI: Validators should return generic error strings, not locale-specific or context-aware messages. Let your presentation layer translate them if needed.
Key takeaways
- Use
FormwithGlobalKey<FormState>to coordinate validation and state across multiple fields. - Extract validators into reusable functions for consistency, testability, and maintainability.
- Apply
InputFormatterto shape user input in real-time—for example, auto-formatting phone numbers or enforcing length limits. - Manage submit state explicitly: disable inputs, show progress indicators, handle errors, and reset on success.
- Always dispose controllers and check
mountedafter async operations to avoid memory leaks and crashes.






Comments
Loading comments…