Flutter Form Validation: The Form, GlobalKey, and Validator Pattern
A practical walkthrough of Form + GlobalKey<FormState> + TextFormField validators, including AutovalidateMode and nested form access.
Published on • October 7, 2026
AI Assistant

Forms are where apps earn trust: a login, a checkout, a profile edit. Flutter’s form validation system is small — three widgets and one method — but the details determine whether your UX feels polished or hostile.
This walkthrough follows the official Build a form with validation cookbook, then covers the details the cookbook assumes.
Step 1: Create a Form with a GlobalKey
Form is a container that groups fields and validates them as a unit. It needs a GlobalKey<FormState> so you can reach its state later:
class MyCustomForm extends StatefulWidget {
const MyCustomForm({super.key});
@override
State<MyCustomForm> createState() => _MyCustomFormState();
}
class _MyCustomFormState extends State<MyCustomForm> {
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: const Column(
children: [
// TextFormFields and ElevatedButton go here
],
),
);
}
}
Two details from the docs worth internalizing:
- The form must be a
StatefulWidget. AGlobalKey<FormState>()must be created exactly once. If you made this aStatelessWidget, you’d have to store the key somewhere — and recreating it everybuild()is expensive and breaks state identity. - It’s
GlobalKey<FormState>, notGlobalKey<_MyCustomFormState>. The key types the form’s state, which Flutter creates automatically when it builds theForm.
Step 2: Add TextFormField with Validation Logic
TextFormField wraps a Material text field with form integration. Validation is a function you provide:
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter some text';
}
return null;
},
),
The contract is simple: return a String error message when invalid, null when valid. The returned string is what renders beneath the field.
A realistic email validator:
validator: (value) {
if (value == null || value.isEmpty) return 'Email is required';
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!emailRegex.hasMatch(value)) return 'Enter a valid email address';
return null;
},
Step 3: Validate and Submit
Wire a button that asks the form whether everything passed:
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
}
},
child: const Text('Submit'),
)
How this actually works
_formKey.currentState resolves to the FormState object Flutter created when building the Form. FormState.validate() then:
- Runs every field’s
validator()in the subtree. - If all return
null, returnstrue. - If any returns a string, rebuilds the form so error messages display, and returns
false.
The rebuild step is why errors appear the instant you tap Submit — you don’t need to trigger anything manually.
_formKey.currentState! uses a null assertion because currentState is null before the Form is mounted. Inside a button’s onPressed, the form exists. If you call validate() during initState or before first build, you’ll crash.
Auto-Validating: AutovalidateMode
Validating only on submit is the cookbook’s default, but most modern apps validate as the user types or when they leave a field. Set autovalidateMode on the Form or on individual TextFormFields:
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: ...
)
The available modes the docs call out:
AutovalidateMode.disabled— onlyvalidate()triggers checks (the cookbook default).AutovalidateMode.onUserInteraction— validate once the user has interacted with a field.AutovalidateMode.onUnfocus— validate when a field loses focus (newer, gentler UX).AutovalidateMode.always— validate every build; useful in tests, noisy in production.
Per-field override lets you be strict about some fields and relaxed about others:
TextFormField(
autovalidateMode: AutovalidateMode.onUnfocus,
validator: (v) => v == null || v.isEmpty ? 'Required' : null,
)
Accessing the Form from Nested Widgets
The docs note that a GlobalKey is the recommended access pattern, but for complex widget trees you can reach the form without threading the key down:
// From a descendant of the Form:
Form.of(context).validate();
Form.of(context) finds the nearest ancestor Form via the inherited-widget mechanism. Keep the GlobalKey at the top for the submit button; use Form.of() for deep descendants.
Putting It Together
final _formKey = GlobalKey<FormState>();
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUnfocus,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) return 'Email is required';
if (!value.contains('@')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Call your API / save to a database here
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
}
},
child: const Text('Submit'),
),
],
),
)
Retrieving Values
Validation only tells you whether input is valid. To read the actual values, attach TextEditingControllers to each field and read controller.text after validate() passes — see the cookbook’s Retrieve the value of a text field recipe.
Notes for Flutter 3.47+
The Form/FormState/GlobalKey API is unchanged, but the surrounding landscape shifted: Material widgets are moving into the standalone material_ui package, and the TextFormField API reference now lives in that package’s docs. Code written against package:flutter/material.dart continues to work; expect dart fix migrations as the package split rolls out.
Common Pitfalls
- Recreating
_formKeyinbuild()— the form loses state every frame. Declare it as a field onState. - Empty-string vs null — validators receive
nullonly if the field was never edited in some paths; check both. - Validating before first build —
_formKey.currentStateis null until mounted; guard with?.if calling outside callbacks. - Forgetting the rebuild —
validate()rebuilds the form itself, but if you change validation logic based on external state, callsetState.