Validating and customizing TextFormFields, the Flutter way
There are numerous ways to build forms with validation in Flutter, including using third-party packages. But why rely on external packages when you can achieve plenty with just the built-in Form and TextFormField widgets? This article will demonstrate the powerful customizability and robustness within Flutter alone, allowing you to build forms with custom-looking TextFormFields while implementing complex validation logic without relying on external packages.
What areForm, TextField and TextFormField ?
Form : groups and validates multiple form fields
TextField: accepts text input
TextFormField: TextField with validation
Basic validation techniques
The most basic form of validation can be seen in the example here. I have copied part of the example here for easier reference:
// This class holds data related to the form.
class MyCustomFormState extends State<MyCustomForm> {
// Create a global key that uniquely identifies the Form widget
// and allows validation of the form.
//
// Note: This is a GlobalKey<FormState>,
// not a GlobalKey<MyCustomFormState>.
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
// Build a Form widget using the _formKey created above.
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter some text';
}
return null;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: () {
// Validate returns true if the form is valid, or false otherwise.
if (_formKey.currentState!.validate()) {
// If the form is valid, display a snackbar. In the real world,
// you'd often call a server or save the information in a database.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
}
},
child: const Text('Submit'),
),
),
],
),
);
}
}The validator in each TextFormField is responsible for validating the input value of that field, and either returns an error message or null if the input value is valid. But that’s not all, you also have control over when to validate.
Validation on submit
A formKey can be assigned to the Form widget, which can then be used to call validate() to trigger validations on all fields nested under it. This is useful when you want to trigger validation when the form is going to be submitted (e.g. the onPressed of the submit button).
Validation on render
Sometimes, the validation should be triggered as soon as the form or field becomes visible to users. In this case, use AutovalidateMode.always in Form or TextFormField :
Form(
// trigger validation as soon as the form becomes visible
autovalidateMode: AutovalidateMode.always,
child: Column(
children: [
TextFormField(
// trigger validation as soon as the field becomes visible
autovalidateMode: AutovalidateMode.always,
),
],
),
),Validation on change
This is most commonly used. Validation is triggered only when the form or a field value has been updated by users when we use AutovalidateMode.onUserInteraction:
Form(
// trigger validation as soon as one of the form value has been changed
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
TextFormField(
// trigger validation as soon as this field value has been changed
autovalidateMode: AutovalidateMode.onUserInteraction,
),
],
),
),Disable validation
Sometimes you might want to skip validation altogether, especially when the form is not dirty (dirty means the form has been submitted by the user at least once, it is a common UX practice to prevent displaying errors before the user had a chance to edit the form). By using AutovalidateMode.disabled, you can disable validation on Form or TextFormField:
Form(
// in practice, you might want to make this conditional
// e.g. isFormDirty
// ? AutovalidateMode.onUserInteraction
// : AutovalidateMode.disabled
autovalidateMode: AutovalidateMode.disabled,
child: Column(
children: [
TextFormField(
// same concept here, but applied to individual fields instead of the whole form
autovalidateMode: AutovalidateMode.disabled,
),
],
),
),Validation with regular expressions (regex)
This is as straightforward as checking if the value is empty or null. Simply put in the if-statement in the validator property:
// see https://stackoverflow.com/a/742455/14052058 for discussion
RegExp get _emailRegex => RegExp(r'^\S+@\S+$');
@override
Widget build(BuildContext context) {
return TextFormField(
validator: (value) {
if (!_emailRegex.hasMatch(value)) {
return 'Email address is not valid';
}
},
);
}Validate fields that are dependent on each other
Sometimes you want to compare multiple field values and validate based on that. A classic example is the password and the confirm password field. Let’s define the password field first:
TextFormField(
decoration: InputDecoration(
labelText: 'Password',
),
validator: (value) {
// validate just the password here (no confirm password!)
},
onChanged: (value) {
// important: this is needed to obtain the current password value
// create a state variable in the class that contains your Form widget
// with type String to store the password value
// e.g. String? password;
setState(() {
password = value;
});
},
),Now the confirm password field:
TextFormField(
decoration: InputDecoration(
labelText: 'Confirm Password',
),
validator: (value) {
// since we stored the password value in the state, we can access the
// variable here and compare the value
if (value != password) {
return 'The password you entered is different';
}
},
),Async validation errors
Async validation errors are sometimes known as server validation errors or submission errors. How do you show an error message from the server, e.g. the email address is already registered, or the username is already taken? How do you show that error message under a text field?
Get Yong Shean’s stories in your inbox
Join Medium for free to get updates from this writer.
Let’s have a look at the email example. Assuming you want to check if an email is already registered when the user taps on the submit button, then in your button implementation:
ElevatedButton(
label: 'Sign up',
onTap: () async {
if (_formKey.currentState.validate()) {
try {
// make API call through your service layer to sign up
await signUp();
} catch (error) {
// assuming your service layer will throw a specific exception
// e.g. EmailAlreadyRegisteredException
// check out the full source code on how that is achieved
if (error is EmailAlreadyRegisteredException) {
setState(() {
// create a new state variable so you can flag this to true
// when you receive this exception on sign up
isEmailAlreadyRegistered = true;
});
} else {
ScaffoldMessenger.of(context)showSnackBar(
const SnackBar(
content: Text('Something went wrong. Please try again later.'),
),
);
}
}
}
},
),Now you have a state variable isEmailAlreadyRegistered that keeps track if the server returns the error, what is left to do is to hook that up in the email field:
TextFormField(
decoration: InputDecoration(
labelText: 'Email',
// this is a workaround for async validations
errorText: isEmailAlreadyRegistered
? 'Email address is already registered'
: null,
),
),Clear the async validation error as soon as the user has started editing the field
It makes sense for validation errors to go away as soon as the input value becomes valid. However, we might not want to perform async validation calls on every keystroke or change in the text fields. In that case, we can hide the async validation errors as soon as the user has started to edit the text field in question:
TextFormField(
decoration: InputDecoration(
labelText: 'Email',
// this is a workaround for async validations
errorText: isEmailAlreadyRegistered
? 'Email address is already registered'
: null,
),
onChanged: (value) {
// clear submission error of email field
setState(() {
isEmailAlreadyRegistered = false;
});
},
),Customizing the look of text fields
Ok, TextFormField is quite powerful, but I don’t want the Material look on my text field… can I still use TextFormField?
Of course!
The InputDecoration has a lot of customization options that allow you to customize the look of the text area in many ways. Let me explain with a code example:
TextFormField(
decoration: InputDecoration(
// if you want to change the focusColor
// see https://github.com/flutter/flutter/issues/117852#issuecomment-1368611791
// you can have a background colour for your text fields
filled: true,
// you may use conditions to set different colours for different states
fillColor:
_showErrorBelowField ? Colors.red.shade100 : Colors.black12,
// you may use the OutlineInputBorder,
// or extend the InputBorder class to create your own
// the default is UnderlineInputBorder
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
// you can also define different border styles for different states
// e.g. when the field is enabled / focused / has error
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Colors.greenAccent),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Colors.red),
),
),
),Since TextFormField is designed to fit the Material guideline, the built-in properties such as InputDecoration might not be customized to show a non-Material look. Let’s have a look:
// we extract the validator function here to make it easier to read
String? Function(String?)? get validator => (value) {
if (value == null || value.isEmpty) {
return 'Username cannot be empty';
}
if (!_usernameRegex.hasMatch(value)) {
return 'Username is not valid';
}
return null;
};
// we introduce a new getter _showErrorBelowField
// to keep track whenever there is a validation error, including async
bool get _showErrorBelowField => _validationError != null || isUsernameAlreadyTaken;
// use column to stack the widgets vertically
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// you are free to define how to display the label text
// since it is no longer part of TextFormField
Text(
'Username',
style: TextStyle(
fontSize: 12,
color: _showErrorBelowField ? Colors.red : Colors.black54,
),
),
TextFormField(
decoration: InputDecoration(
// ... more options available, see example above
// we still keep the errorText to return an empty string when there is an error
// but set the height to zero so it will not occupy extra space below the field
errorText: _showErrorBelowField ? '' : null,
errorStyle: const TextStyle(height: 0),
),
onChanged: (value) {
// clear submission error of username field
widget.setIsUsernameAlreadyTaken(false);
},
validator: (value) {
// instead of returning the error messages here
// we assign the error message to the _validationError state
// so that we can show the error message elsewhere
final error = validator?.call(value);
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_validationError = error;
});
});
return null;
},
textInputAction: TextInputAction.next,
),
if (_showErrorBelowField)
// again you are free to define how to display the error text
// since it is no longer part of TextFormField
Text(
widget.isUsernameAlreadyTaken
? 'Username is already taken'
: _validationError!,
style: TextStyle(
fontSize: 12,
color: _showErrorBelowField ? Colors.red : Colors.black,
),
),
],
),See? No external package is needed! I feel that it is such a waste if we don’t make use of the full power offered within Flutter alone, and end up reinventing the wheel. If you are interested to see how everything works together, the full source code (which comes with real APIs! Thanks to Real-World Flutter by Tutorials) is available in my Github repository.
Did the article help? Did I make a mistake? Do you have a suggestion? Your feedback is well appreciated. Thank you for reading and happy coding!






