The Form Widget & GlobalKey
The Form Widget & GlobalKey
Flutter provides a dedicated Form widget that acts as a container for a group of form fields. Rather than managing each field's validation logic independently, the Form widget lets you coordinate validation, saving, and resetting across all of its descendant FormField widgets at once. This is the standard, idiomatic approach to building any input form in Flutter.
Why Use a Form Widget?
Without a Form widget you would need to manually call each field's validator, track whether fields have been interacted with, and reset them one by one. The Form widget solves all of this by:
- Grouping related
FormFieldwidgets under one coordinating parent - Exposing a
FormStateobject that hasvalidate(),save(), andreset()methods - Triggering validation on all descendant fields simultaneously when you call
validate() - Calling the
onSavedcallback on every field when you callsave() - Clearing all field values and error text when you call
reset()
FormField subclass you will use is TextFormField, which combines a TextField with built-in support for validator, onSaved, and initialValue. Any widget that extends FormField<T> participates in the parent Form automatically.Introducing GlobalKey<FormState>
To call methods on a Form from outside its build method — for example, from a button's onPressed — you need a reference to its underlying FormState. Flutter's key system provides this. A GlobalKey<FormState> is a unique key that, once attached to a Form widget, lets you retrieve that form's FormState at any time via _formKey.currentState.
Declaring and Attaching a GlobalKey
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
// 1. Declare the key as a field on the State class.
// It must persist across rebuilds, so it lives here — NOT inside build().
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(
// 2. Attach the key to the Form widget.
key: _formKey,
child: Column(
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) return 'Please enter your email';
return null; // null means valid
},
),
ElevatedButton(
onPressed: _submit,
child: const Text('Submit'),
),
],
),
);
}
void _submit() {
// 3. Access FormState via currentState and call validate().
if (_formKey.currentState!.validate()) {
// All validators returned null — form is valid.
_formKey.currentState!.save();
}
}
}
GlobalKey inside the build() method. Every time build() runs a new key instance would be created, causing Flutter to treat the Form as a brand-new widget and lose all field state. Always declare the key as an instance variable on your State class.The Three Core FormState Methods
Once you hold a reference to the FormState via _formKey.currentState, you have access to three essential methods:
validate()— Runs every field'svalidatorcallback. Returnstrueif all validators returnnull(meaning valid), orfalseif any returned an error string. Also displays error text below invalid fields.save()— Calls theonSavedcallback on everyFormFielddescendant, allowing you to collect the current values into your own variables.reset()— Resets every field to itsinitialValueand clears all validation error messages.
Using validate(), save(), and reset()
class RegistrationForm extends StatefulWidget {
const RegistrationForm({super.key});
@override
State<RegistrationForm> createState() => _RegistrationFormState();
}
class _RegistrationFormState extends State<RegistrationForm> {
final _formKey = GlobalKey<FormState>();
String _name = '';
String _email = '';
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) => (v == null || v.trim().isEmpty) ? 'Name is required' : null,
onSaved: (v) => _name = v!.trim(),
),
const SizedBox(height: 12),
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.isEmpty) return 'Email is required';
if (!v.contains('@')) return 'Enter a valid email';
return null;
},
onSaved: (v) => _email = v!,
),
const SizedBox(height: 24),
Row(
children: [
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
// _name and _email are now populated
debugPrint('Registering: $_name <$_email>');
}
},
child: const Text('Register'),
),
const SizedBox(width: 12),
TextButton(
// reset() clears values and error messages
onPressed: () => _formKey.currentState!.reset(),
child: const Text('Clear'),
),
],
),
],
),
);
}
}
Understanding autovalidateMode
By default a Form only shows validation errors after you explicitly call validate(). You can change this with the autovalidateMode property on either the Form or individual TextFormField widgets:
AutovalidateMode.disabled— (default) Validate only whenvalidate()is called.AutovalidateMode.onUserInteraction— Validates a field automatically after the user first interacts with it.AutovalidateMode.always— Validates on every rebuild, even before interaction (rarely recommended for UX reasons).
AutovalidateMode.onUserInteraction is the most user-friendly setting. It avoids showing error messages on a blank untouched form, but gives instant feedback once the user has typed something and moved on. Use it on forms where you want real-time feedback without an overwhelming first impression.Summary
The Form widget is the foundation of all structured user input in Flutter. It works in tandem with a GlobalKey<FormState> that must be declared as a persistent instance variable on your State class. Through _formKey.currentState you can call validate() to run all validators at once, save() to collect values, and reset() to clear the form. This pattern keeps form logic centralised and your build method clean.