In a previous article, I wrote about how separating Flutter views into distinct layers helps us create more robust and joyful mobile apps. If you missed that post, you can read it here: A Clever Way to Structure Flutter Apps. Today, I’ll build on that by walking through how I handle forms.
To start, here’s an example of a basic form with validation from the official Flutter docs:
Source: https://docs.flutter.dev/cookbook/forms/validation
class MyCustomForm extends StatefulWidget {
const MyCustomForm({super.key});
@override
MyCustomFormState createState() {
return MyCustomFormState();
}
}
class MyCustomFormState extends State<MyCustomForm> {
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter some text';
}
return null;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
}
},
child: const Text('Submit'),
),
),
],
),
);
}
}
This example only shows form validation. Since my focus right now is structure, I’ll get into saving values later in this post.
From the start, I’ve preferred to keep my views as simple as possible. When forms are added directly to views in the code, like in the example, the complexity increases with each added input. Also, some inputs - like password fields - may be reusable throughout an app, with consistent logic, but different styling. In the password creation and recovery workflows, the validation logic is the same, but the styling doesn’t have to. To separate the logic from the view and prevent duplication, I use a Form Class.
This class holds functions that build inputs without wrapping them in a style. This keeps views simpler and creates a clear, concise list of the inputs and their validation logic.
Before going deeper, though, here’s how to convert this example into the frontend structure I use:
class MyCustomFormView extends StatelessWidget {
const MyCustomFormView({super.key});
@override
Widget build(BuildContext context) {
var controller = Provider.of<MyCustomFormController>(context);
var formKey = controller.formKey;
return Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter some text';
}
return null;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
}
},
child: const Text('Submit'),
),
),
],
),
);
}
}
class MyCustomFormController extends ChangeNotifier {
final formKey = GlobalKey<FormState>();
}
The StatefulWidget MyCustomForm
is now the StatelessWidget MyCustomFormView
. Giving the view a MyCustomFormController
for state lets me move the form key into the controller.
This works, but the form key is only going to live here temporarily. Managing form state in the controller can create issues, so leaving the form key implementation in the controller is not ideal. Because TextFormFields
are stateful, their values will persist through rebuilds. In the example, the persistence of the entire form, including the field values, is tied to the persistence of the controller, meaning that depending on how and where the ChangeNotifierProviders
are set, form fields may persist longer than intended.
Luckily, a state management mess can be avoided by managing the form key with a BaseForm Class. Here’s what that looks like:
class ExampleForm extends BaseForm {
String? exampleText;
buildExampleInput() {
return TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter some text';
}
return null;
},
onSaved: (text) {
exampleText = text;
},
);
}
}
class BaseForm {
late GlobalKey<FormState> formKey;
BaseForm() {
formKey = GlobalKey<FormState>();
}
bool onSubmit() {
if (formKey.currentState!.validate()) {
formKey.currentState!.save();
return true;
}
return false;
}
}
The ExampleForm is the implementation class, and it defines:
Validator functions provide structure to ensure the data flowing through the controller is in the right shape before being sent off to the backend. In the ExampleForm
’s example input, I check for null and whether the value is empty. If the validator returns null, the validation is successful.
The onSaved
callback stores data if all validators within the form succeed. In the example, the input value is saved into exampleText
as a class property, but it could be saved to a model instead.
The BaseForm class acts as an abstract parent to all forms. It only contains form-specific logic - absolutely no business logic allowed! When each form class implementation extends the BaseForm, a new form key is created on instantiation. This makes it simple to wipe the data from the widgets on a view completely, since all we have to do to reset a form’s state is re–instantiate the form class.
The onSubmit
function validates and saves all the fields built in the form widget. When the validations pass and the data is saved to the form class, the BaseForm’s onSubmit
function returns true.
Here’s how I wire everything into a controller, and render it in the view:
class MyCustomFormController extends ChangeNotifier {
ExampleForm form = ExampleForm();
ExampleService service = ExampleService();
clearForm() {
form = ExampleForm();
notifyListeners();
}
Future<bool> submitForm() async {
var valid = false;
if (!form.onSubmit()) return false;
try {
await service.saveForm(form);
valid = true;
} catch (e) {
valid = false;
}
return valid;
}
}
class MyCustomFormView extends StatelessWidget {
const MyCustomFormView({super.key});
@override
Widget build(BuildContext context) {
var controller = Provider.of<MyCustomFormController>(context);
var form = controller.form;
return Form(
key: form.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
form.buildExampleInput(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ElevatedButton(
onPressed: () async {
var valid = await controller.submitForm();
if (valid) {
redirect.to(...)
}
},
child: const Text('Submit'),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ElevatedButton(
onPressed: () => controller.clearForm(),
child: const Text('Start Over'),
),
),
],
),
);
}
}
In this setup, the controller defines the functions that represent user actions in the view, including the ExampleForm. The view renders inputs from the form inside the controller, and creates the buttons that call the actions in the controller on press. This effectively makes the UI functional while keeping the view simple, and the logic clean.
One key detail: make sure that the Form widget uses the key from the form class.
To ensure the correct inputs get validated and saved, in the view, the form key must pass to the Form widget from the controller’s exampleForm
, via the formKey
property. When multiple form classes are used together, in a single widget, for instance, only one of their form keys can be used in the Form widget’s formKey
property. For validation and saving to work, that same form key must belong to the form that’s used to call onSubmit()
.
There is one last pattern that may throw you for a loop: input widgets can affect each other. Stateful widgets can rebuild on their own if their values change, but some widgets may need to change state depending on other widgets’ values. By passing the function that handles state management (i.e. NotifyListeners
, setState
) into the input builder, you can ensure any listening widgets also rebuild.
For this last example, I’ll use a pair of toggles:
class ToggleForm extends BaseForm {
bool toggleValue = false;
buildToggle(notifyListeners) {
return CustomToggle(
value: toggleValue,
onToggle: () {
toggleValue = !toggleValue;
notifyListeners();
}
);
}
buildOppositeToggle() {
return CustomToggle(
value: !toggleValue,
onToggle: () { }
);
}
}
class MyCustomFormView extends StatelessWidget {
const MyCustomFormView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
var controller = Provider.of<MyCustomFormController>(context);
var form = controller.form;
return Form(
key: form.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
form.buildToggle(controller.notifyListeners),
form.buildOppositeToggle(),
],
),
);
}
}
Passing notifyListeners
into the input builder gives the toggle the ability to trigger a change that updates the state of the other toggle in the view.
TL;DR: To keep views clean and simple, and your code easy to work with, consider writing your form inputs into builder functions inside a dedicated form class. Not only will the input builder functions be reusable in cases such as a password field or MFA field, you’ll be able to see the validation logic without scrolling through all the styling logic of the view.
Till next time, keep Clevyr.