Ever needed to use a TextField
and validate the text on the fly as the user types?
In this example, we show a custom error hint and disable the submit button if the text is empty or too short.
If you wanted to implement this functionality in Flutter, how would you do it?
People on StackOverflow seem to have many opinions about it, and indeed there are two main ways of doing this:
- Use a
TextField
with aTextEditingController
and aValueListenableBuilder
to update the UI. - Use a
Form
and aTextFormField
along with aGlobalKey
to validate and save the text field data.
In this article, we'll explore both solutions so you can learn how to work with text input in Flutter.
1. Flutter TextField Validation with TextEditingController
To get started, let's build the basic UI first.
Basic UI with TextField and ElevatedButton
Step one is to create a StatefulWidget
subclass that will contain both our TextField
and the submit button:
class TextSubmitWidget extends StatefulWidget {
const TextSubmitWidget({Key? key, required this.onSubmit}) : super(key: key);
final ValueChanged<String> onSubmit;
@override
State<TextSubmitWidget> createState() => _TextSubmitWidgetState();
}
Note how we added an
onSubmit
callback. We will use this to inform the parent widget when the user presses the "Submit" button upon entering a valid text.
Next, let's create the State
subclass:
class _TextSubmitWidgetState extends State<TextSubmitWidget> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: InputDecoration(
labelText: 'Enter your name',
// TODO: add errorHint
),
),
ElevatedButton(
// TODO: implement callback
onPressed: () {},
child: Text(
'Submit',
style: Theme.of(context).textTheme.headline6,
),
)
],
);
}
}
This is a simple Column
layout that contains a TextField
and an ElevatedButton
.
If we run this code inside a single-page Flutter app, both the text field and the submit button will show:
Next, we want to add all the validation logic and update the UI according to these rules:
- if the text is empty, disable the submit button and show
Can't be empty
as an error hint - if the text is not empty but too short, enable the submit button and show
Too short
as an error hint - if the text is long enough, enable the submit button and remove the error hint
Let's figure out how to implement this.
Adding a TextEditingController
Flutter gives us a TextEditingController class that we can use to control our text field.
So let's use it. All we have to do is to create it inside the State
subclass:
class _TextSubmitWidgetState extends State<TextSubmitWidget> {
// create a TextEditingController
final _controller = TextEditingController();
// dispose it when the widget is unmounted
@override
void dispose() {
_controller.dispose();
super.dispose();
}
...
}
And then we can pass it to our TextField
:
TextField(
// use this to control the text field
controller: _controller,
decoration: InputDecoration(
labelText: 'Enter your name',
),
),
We can also add a getter variable to control the _errorText
that we pass to the TextField
:
String? get _errorText {
// at any time, we can get the text from _controller.value.text
final text = _controller.value.text;
// Note: you can do your own custom validation here
// Move this logic this outside the widget for more testable code
if (text.isEmpty) {
return 'Can\'t be empty';
}
if (text.length < 4) {
return 'Too short';
}
// return null if the text is valid
return null;
}
// then, in the build method:
TextField(
controller: _controller,
decoration: InputDecoration(
labelText: 'Enter your name',
// use the getter variable defined above
errorText: _errorText,
),
),
With this in place, we can add some custom logic to the onPressed
callback inside our button:
ElevatedButton(
// only enable the button if the text is not empty
onPressed: _controller.value.text.isNotEmpty
? _submit
: null,
child: Text(
'Submit',
style: Theme.of(context).textTheme.headline6,
),
)
Note how we call a _submit
method if the text is not empty. This is defined as follows:
void _submit() {
// if there is no error text
if (_errorText == null) {
// notify the parent widget via the onSubmit callback
widget.onSubmit(_controller.value.text);
}
}
But if we run this code, the TextField
always shows the error text and the submit button remains disabled even if we enter a valid text:
What's the deal? 🧐
Widget rebuilds and setState()
The problem is that we are not telling Flutter to rebuild our widget when the text changes.
We could fix this by adding a local state variable and updating it with a call to setState()
inside the onChanged
callback of our TextField
:
// In the state class
var _text = '';
// inside the build method:
TextField(
controller: _controller,
decoration: InputDecoration(
labelText: 'Enter your name',
errorText: errorText,
),
// this will cause the widget to rebuild whenever the text changes
onChanged: (text) => setState(() => _text),
),
With this change our UI updates on the fly and behaves as expected:
But a local state variable is not necessary because our TextEditingController
already holds the text value as it changes.
As a proof of this, we could make an empty call to setState()
and everything would still work:
onChanged: (_) => setState(() {}),
But forcing a widget rebuild like this seems a bit of an anti-pattern. There must be a better way.
How to use TextEditingController with ValueListenableBuilder
As it turns out, we can wrap our widget tree with a ValueListenableBuilder that takes our TextEditingController
as an argument:
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
// Note: pass _controller to the animation argument
valueListenable: _controller,
builder: (context, TextEditingValue value, __) {
// this entire widget tree will rebuild every time
// the controller value changes
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _controller,
decoration: InputDecoration(
labelText: 'Enter your name',
errorText: _errorText,
),
),
ElevatedButton(
onPressed: _controller.value.text.isNotEmpty
? _submit
: null,
child: Text(
'Submit',
style: Theme.of(context).textTheme.headline6,
),
)
],
);
},
);
}
As a result, both the TextField
and ElevatedButton
will rebuild when the text changes:
But why are we allowed to pass our TextEditingController
to the ValueListenableBuilder
?
ValueListenableBuilder(
// this is valid because TextEditingController implements Listenable
valueListenable: _controller,
builder: (context, TextEditingValue value, __) { ... }
)
Well, ValueListenableBuilder
takes an argument of type ValueListenable<T>
.
And TextEditingController
extends ValueNotifier<TextEditingValue>
, which implements ValueListenable<ValueListenable>
. This is how these classes are defined in the Flutter SDK:
class TextEditingController extends ValueNotifier<TextEditingValue> { ... }
class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T> { ... }
So here we have it. We can use ValueListenableBuilder
to rebuild our UI when the TextEditingController
value changes. 👍
Note about the validation UX
The example above works but has one drawback: we are showing a validation error right away before the user has the chance to enter any text.
This is not good UX. It would be better to only show any errors after the text has been submitted.
We can fix this by adding a _submitted
state variable that is only set to true
when the submit button is pressed:
class _TextSubmitWidgetState extends State<TextSubmitWidget> {
bool _submitted = false;
void _submit() {
setState(() => _submitted = true);
if (_errorText == null) {
widget.onSubmit(_controller.value.text);
}
}
...
}
Then, we can use it to conditionally show the error text:
TextField(
controller: _controller,
decoration: InputDecoration(
labelText: 'Enter your name',
// only show the error text if the form was submitted
errorText: _submitted ? _errorText : null,
),
)
And with this change in place, the error text only shows after we submit the form:
Much better.
Flutter TextField Validation with TextEditingController: Summary
Here are the key points we covered so far:
- When we work with text input, we can use
TextEditingController
to get the value of aTextField
. - If we want our widgets to rebuild when the text changes, we can wrap them with a
ValueListenableBuilder
that takes theTextEditingController
as an argument.
This works, but it is a bit tricky to set up. Wouldn't it be nice if we could use some high-level APIs to manage form validation?
That's exactly what the Form and TextFormField widgets are for.
So let's figure out how to use them by implementing the same solution with a form.
2. Flutter Form Validation with TextFormField
Here's an alternative implementation of the _TextSubmitWidgetState
that uses a Form
:
class _TextSubmitWidgetState extends State<TextSubmitForm> {
// declare a GlobalKey
final _formKey = GlobalKey<FormState>();
// declare a variable to keep track of the input text
String _name = '';
void _submit() {
// validate all the form fields
if (_formKey.currentState!.validate()) {
// on success, notify the parent widget
widget.onSubmit(_name);
}
}
@override
Widget build(BuildContext context) {
// build a Form widget using the _formKey created above.
return Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
decoration: const InputDecoration(
labelText: 'Enter your name',
),
// use the validator to return an error string (or null) based on the input text
validator: (text) {
if (text == null || text.isEmpty) {
return 'Can\'t be empty';
}
if (text.length < 4) {
return 'Too short';
}
return null;
},
// update the state variable when the text changes
onChanged: (text) => setState(() => _name = text),
),
ElevatedButton(
// only enable the button if the text is not empty
onPressed: _name.isNotEmpty ? _submit : null,
child: Text(
'Submit',
style: Theme.of(context).textTheme.headline6,
),
),
],
),
);
}
}
Here's how the code above works:
- We declare a
GlobalKey
that we can use to access the form state and pass it as an argument to theForm
widget. - We use a
TextFormField
rather than aTextField
. - This takes a
validator
function argument that we can use to specify our validation logic. - We use a separate
_name
state variable and update it in theonChanged
callback of theTextFormField
widget (note how this is used in theonPressed
callback of theElevatedButton
). - Inside the
_submit()
method, we call_formKey.currentState!.validate()
to validate all the form data. If this is successful, we notify the parent widget by callingwidget.onSubmit(_name)
.
The Flutter FormState class gives us validate and save methods that make it easier to manage the form data.
AutovalidateMode
To decide when the TextFormField
validation takes place, we can pass an autovalidateMode
argument. This is an enum
defined as follows:
/// Used to configure the auto validation of [FormField] and [Form] widgets.
enum AutovalidateMode {
/// No auto validation will occur.
disabled,
/// Used to auto-validate [Form] and [FormField] even without user interaction.
always,
/// Used to auto-validate [Form] and [FormField] only after each user
/// interaction.
onUserInteraction,
}
By default, AutovalidateMode.disabled
is used.
We could change this to AutovalidateMode.onUserInteraction
so that our TextFormField
validates when the text changes:
TextFormField(
decoration: const InputDecoration(
labelText: 'Enter your name',
),
// validate after each user interaction
autovalidateMode: AutovalidateMode.onUserInteraction,
// The validator receives the text that the user has entered.
validator: (text) {
if (text == null || text.isEmpty) {
return 'Can\'t be empty';
}
if (text.length < 4) {
return 'Too short';
}
return null;
},
)
But as we said before, we only want to enable validation after the form has been submitted.
So let's add a _submitted
variable like we did before:
class _TextSubmitFormState extends State<TextSubmitForm> {
final _formKey = GlobalKey<FormState>();
String _name = '';
// use this to keep track of when the form is submitted
bool _submitted = false;
void _submit() {
// set this variable to true when we try to submit
setState(() => _submitted = true);
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
widget.onSubmit(_name);
}
}
}
Then, inside the TextFormField
we can do this:
TextFormField(
autovalidateMode: _submitted
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
)
The end result is exactly what we want: the error hint only shows after we submit the form if the text is invalid:
Conclusion
We have now explored two different ways of validating a form in Flutter.
You can find the complete source code and play with both solutions on Dartpad:
Which one should you use?
I recommend using Form and TextFormField, as they give you some high-level APIs that make it easier to work text input, and they are better suited if you have multiple form fields on the same page.
With that said, TextEditingController
gives you more fine-grained control and lets you get and set the text, which can be handy when you need to pre-fill a text field. You can find more details in the TextEditingController documentation.
And if you want to learn more about working with forms, check the official documentation on Flutter.dev.
Happy coding!