In almost every app that you develop, sooner or later there will arise a need to capture user input. Fortunately, capturing text inputs is fairly simple within Flutter. However, as more fields and input types are added to a form, capturing this information rapidly increases in complexity.
Normally, these input fields, whether they are text fields, date fields, or any other type of input, are referred to as “controls.” Validation can also become an issue, as even simple validation for certain fields can require lengthy custom validators to be written.
In this article, we’ll create a registration form with input validation and fields that change based on the value of other fields. We’ll first accomplish this without using reactive forms, then reimplement the same form using reactive forms to understand the benefits of reactive forms in Flutter.
What we’ll cover:
flutter_form_builder
to create reactive forms in Flutter
The app we will create is an enrollment app for pets into a “pet hotel” — a place where people can drop their pets off when they go on vacation.
In order for this app to work, people need to give details such as their name and phone number, what kind of pet they have, and their pet’s likes and dislikes. The end result will look like this:
This form has a few requirements.
First, the three followup questions must change depending on what type of pet the user selects.
Next, the answers to those three questions are required, so we must add Flutter form validation logic to ensure they are filled out.
Finally, the phone number must only contain numbers, so if it contains non-numeric values, then the form should reject that entry and inform the user.
In this first approach, we’re manually creating the forms ourselves, and we would also like to capture the text inputs that are in these individual fields.
Because of this, we’re responsible for creating individual TextControllers
that we can associate to the TextFormField
widgets. We’re also responsible for creating a variable that will house the selected pet.
Let’s create those variables now:
final _formKey = GlobalKey<FormState>(); PetType? _petType; final firstName = TextEditingController(); final lastName = TextEditingController(); final questionResponses = List.generate(3, (index) => TextEditingController());
To write text into these fields, we’ll create the TextFormField
widgets and bind them to the appropriate controllers:
TextFormField( decoration: InputDecoration(hintText: 'First Name'), controller: firstName, ), TextFormField( decoration: InputDecoration(hintText: 'Last Name'), controller: lastName, ),
The phone number input field is a little bit different, as we need to both validate that it has a valid phone number in it as well as prompt the user when invalid input is detected:
TextFormField( decoration: InputDecoration(hintText: 'Phone number'), autovalidateMode: AutovalidateMode.always, validator: (val) { if (val == null || val == "") { return 'Please enter a phone number'; } if (int.tryParse(val) == null) { return 'Only enter numbers in the phone number field'; } return null; }, ),
Next, we specify the pet chooser. This is a RadioListTile
that lets the user select what kind of pet they are bringing in: Cat, Dog, or Echidna.
When the user selects a type of pet, we also want to iterate through the previous answers given to these questions and clear them so that only one option is selected at a time.
RadioListTile<PetType>( value: PetType.cat, groupValue: _petType, onChanged: (val) => setState(() { for (final controller in questionResponses) { controller.clear(); } _petType = val; }), title: Text('Cat'), ),
Finally, we want to change the questions that we are asking based on what type of pet has been selected.
We can achieve by using a Builder
, that will update the widget tree depending on the value of a given variable. So, if the selected animal type is “Cat,” the form will display the questions for that animal type, and the same for animals of type Dog or Echidna.
Builder( builder: (context) { switch (_petType) { case PetType.cat: return Column( children: [ Text("Aw, it's a cat!"), PetQuestionField(question: 'Can we pat the cat?', controller: questionResponses[0]), PetQuestionField(question: 'Can we put a little outfit on it?', controller: questionResponses[1]), PetQuestionField(question: 'Does it like to jump in boxes?', controller: questionResponses[2]), ], ); case PetType.dog: return Column( children: [ Text("Yay, a puppy! What's its details?"), PetQuestionField(question: 'Can we wash your dog?', controller: questionResponses[0]), PetQuestionField(question: 'What is your dog\'s favourite treat?', controller: questionResponses[1]), PetQuestionField(question: 'Is your dog okay with other dog\'s?', controller: questionResponses[2]), ], ); case PetType.echidna: return Column( children: [ Text("It's a small spiky boi. Can you fill us in on some of the details?"), PetQuestionField(question: 'How spikey is the echidna?', controller: questionResponses[0]), PetQuestionField(question: 'Can we read the echidna a story?', controller: questionResponses[1]), PetQuestionField(question: 'Does it like leafy greens?', controller: questionResponses[2]), ], ); case null: { return Text('Please choose your pet type from above'); } } }, ),
With the individual form controls created, it’s time to create a button for the user to register their pet. This button should only allow the user to proceed if the supplied inputs are valid, and should prompt the user to correct any inputs that could not be validated.
ElevatedButton( onPressed: () { // Form is valid if the form controls are reporting that // they are valid, and a pet type has been specified. final valid = (_formKey.currentState?.validate() ?? false) && _petType != null; if (!valid) { // If it's not valid, prompt the user to fix the form showDialog( context: context, builder: (context) => SimpleDialog( contentPadding: EdgeInsets.all(20), title: Text('Please check the form'), children: [Text('Some details are missing or incorrect. Please check the details and try again.')], )); } else { // If it is valid, show the received values showDialog( context: context, builder: (context) => SimpleDialog( contentPadding: EdgeInsets.all(20), title: Text("All done!"), children: [ Text( "Thanks for all the details! We're going to check your pet in with the following details.", style: Theme.of(context).textTheme.caption, ), Card( child: Column( children: [ Text('First name: ${firstName.text}'), Text('Last name: ${lastName.text}\r\n'), Text('Pet type: ${_petType}'), Text('Response 1: ${questionResponses[0].text}'), Text('Response 2: ${questionResponses[1].text}'), Text('Response 3: ${questionResponses[2].text}'), ], ), ) ], ), ); } }, child: Text('REGISTER'))
Using forms in Flutter isn’t unduly difficult, but hand-crafting our own forms can get a bit laborious. Let’s break down why that’s the case.
First, if you want to be able to get the text from a field or clear the field’s input, you have to create your own TextEditingController
for each field. It’s easy to see how you could wind up with quite a few of these, which you would have to keep track of yourself.
Second, you have to write your own validation logic for simple things such as checking if a number is correct.
Finally, this approach results in quite a lot of boilerplate code. For one or two text fields, it’s not so bad, but it’s easy to see how it could scale poorly.
If we were to set off on a journey to find a package that would make this process easier, and we had “reactive forms” in mind, we would probably come across the reactive_forms
Flutter package fairly quickly. And yet, it’s not the package that I would use to create reactive forms within my app.
Why not?
Well, the first sentence on pub.dev tells us that Reactive Forms is “… a model-driven approach to handling Forms inputs and validations, heavily inspired in Angular’s Reactive Forms.”
Due to this, we can establish that the mentality used in the reactive_forms
package will be similar to what we find in Angular.
If we already know Angular, that’s possibly even more of a reason to use reactive_forms
. But if we don’t know Angular, we’re more interested in the simplest way of achieving reactivity within our forms.
In my experience, I find using the package flutter_form_builder
to be an easier, more extensible way of creating forms.
Of course, I encourage you to research both packages and choose the one that you prefer, as one package isn’t necessarily “better” than the other, but they do represent two different ways of achieving a similar result.
flutter_form_builder
to create reactive formsNow let’s use the package flutter_form_builder
to create our forms. This can reduce the amount of code we have to write, make it easier to understand the code we’ve written, and also save us from writing our own validation logic.
First up, we’ll add a dependency to the flutter_form_builder
package in our pubspec.yaml
file:
flutter_form_builder: ^7.4.0
With that set up, let’s reimplement our forms to make use of flutter_form_builder
.
We’ll need to add some names in for the fields that we intend to use within our form. We should set these to a variable name that is logical to us, as we’ll need to bind our FormBuilderTextField
to them later on.
final String FIRST_NAME = 'FirstName'; final String LAST_NAME = 'LastName'; final String PHONE_NUMBER = 'PhoneNumber'; final String PET_CHOICE = 'PetChoice'; final String QUESTION_ANSWER_1 = 'QuestionAnswer1'; final String QUESTION_ANSWER_2 = 'QuestionAnswer2'; final String QUESTION_ANSWER_3 = 'QuestionAnswer3';
We also need to specify a GlobalKey<FormBuilderState>
, to store the details that our form captures.
final _fbKey = GlobalKey<FormBuilderState>();
The next big change is that instead of our form being wrapped in a Form
, we’ll wrap it in a FormBuilder
, and specify a key for the FormBuilder
.
FormBuilder( key: _fbKey, child: Column(children: [...children widgets here]) )
This means the FormBuilder
will store values from the form in this key, so we can easily retrieve them later.
Normally, we would be responsible for manually specifying what TextEditingController
should be used, as well as for setting up things like validation manually. But with flutter_form_builder
, these two things become trivial.
For a text input field, we specify the name
parameter of the field and, if we want to label the field, the decoration. We can also just choose from an existing set of validators instead of writing our own. This means that our first and last name input fields look like this:
FormBuilderTextField( name: FIRST_NAME, decoration: InputDecoration(labelText: 'First Name'), validator: FormBuilderValidators.required(), ),
For our phone number field, instead of writing our own validator, we can just leverage the FormBuilderValidators.numeric()
validator:
FormBuilderTextField( name: PHONE_NUMBER, validator: FormBuilderValidators.numeric(), decoration: InputDecoration(labelText: 'Phone number'), autovalidateMode: AutovalidateMode.always, ),
Now we want to give the user a list of pet type options to choose from by selecting the appropriate radio button in our Flutter app. We can programmatically generate this list from our supplied set of enums.
This means that if we add or remove options from our enum within our program, the options will change within our form as well. This will be easier than manually maintaining the list ourselves.
FormBuilderRadioGroup<PetType>( onChanged: (val) { print(val); setState(() { _petType = val; }); }, name: PET_CHOICE, validator: FormBuilderValidators.required(), orientation: OptionsOrientation.vertical, // Lay out the options vertically options: [ // Retrieve all options from the PetType enum and show them as options // Capitalize the first letters of the options as well ...PetType.values.map( (e) => FormBuilderFieldOption( value: e, child: Text( describeEnum(e).replaceFirst( describeEnum(e)[0], describeEnum(e)[0].toUpperCase(), ), ), ), ), ], ),
Our builder method remains largely the same for this part of our Flutter form, with a couple of important differences: we now use the FormBuilderTextField
class for our inputs, and we associate them to the appropriate entry within the form via the name
parameter.
case PetType.cat: return Column( children: [ Text("Aw, it's a cat!"), FormBuilderTextField( name: QUESTION_ANSWER_1, decoration: InputDecoration(labelText: 'Can we pat the cat?'), ), FormBuilderTextField( name: QUESTION_ANSWER_2, decoration: InputDecoration(labelText: 'Can we put a little outfit on it?'), ), FormBuilderTextField( name: QUESTION_ANSWER_3, decoration: InputDecoration(labelText: 'Does it like to jump in boxes?'), ), ], );
With our reactive Flutter form set up, there are two final things we need to do now: validate that the form has usable data in it and retrieve those values from the form.
Fortunately, because we’ve set the validation requirements within each field itself, our validation becomes quite simple:
final valid = _fbKey.currentState?.saveAndValidate() ?? false;
The result of this operation is that if the current state of our form is not null
, and it is currently considered valid
— that is, all form fields have passed validation — then, the form is considered valid. If currentState
is null
, or the form is invalid
, this variable will instead return false
.
In the case of a successful result, the values will be shown to the user. We can easily access the values within the form by accessing the currentState
object within the _fbKey
object.
showDialog( context: context, builder: (context) => SimpleDialog( contentPadding: EdgeInsets.all(20), title: Text("All done!"), children: [ Text( "Thanks for all the details! We're going to check your pet in with the following details.", style: Theme.of(context).textTheme.caption, ), Card( child: Column( children: [ // It's okay to use the ! operator with currentState, because we // already checked that it wasn't null when we did the form // validation Text('First name: ${_fbKey.currentState!.value[FIRST_NAME]}'), Text('Last name: ${_fbKey.currentState!.value[LAST_NAME]}'), Text('Number: ${_fbKey.currentState!.value[PHONE_NUMBER]}'), Text('Pet type: ${_fbKey.currentState!.value[PET_CHOICE]}'), Text('Response 1: ${_fbKey.currentState!.value[QUESTION_ANSWER_1]}'), Text('Response 2: ${_fbKey.currentState!.value[QUESTION_ANSWER_2]}'), Text('Response 3: ${_fbKey.currentState!.value[QUESTION_ANSWER_3]}'), ], ), ) ], ), );
As we can see, using flutter_form_builder
to create reactive forms in Flutter can lead to many improvements for us as developers. As always, you can browse this project’s code in Github to see how you can use flutter_form_builder
in your project.
You can also use these links below to compare between two commits to see exactly how the project changed:
There are quite a few different types of fields that flutter_form_builder
provides out of the box, so you should always be able to use the right field type for your need.
Have fun, and enjoy building those forms!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowSOLID principles help us keep code flexible. In this article, we’ll examine all of those principles and their implementation using JavaScript.
JavaScript’s Date API has many limitations. Explore alternative libraries like Moment.js, date-fns, and the new Temporal API.
Explore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.