Wrap your fields in a Form, give it a GlobalKey<FormState>, and put a validator on each TextFormField. On submit, call formKey.currentState!.validate(): it runs every validator, shows the returned error strings under the fields, and returns true only when all of them returned null.
- Form is a container that groups FormField widgets; FormState (reached through a GlobalKey<FormState>) can validate, save or reset all of them in one call.
- A validator receives the current text and returns an error String, or null when the value is fine. Returning null is what marks the field as valid.
- autovalidateMode decides when validators run on their own: disabled (only on validate()), onUserInteraction (after the user touches a field), onUnfocus (when a field loses focus), or always.
- To read what the user typed, either attach a TextEditingController (controller.text) or handle onChanged. The controller also lets you set, clear and listen to the text.
- FocusNode controls which field has the keyboard: create it once in State, pass it to the field, call requestFocus() to move focus, and dispose() it with the controllers.
- Keep the key, controllers and focus nodes in a State object. Creating them inside build() recreates them on every rebuild and loses what the user typed.
Validators decide the transition between Editing and Valid/Invalid. With autovalidateMode.disabled (the default) that transition only happens when FormState.validate() is called; with onUserInteraction it happens on every edit after the first.
A paper form and the clerk who checks it
A Form is the paper form; each TextFormField is one box on it. The validator is the checklist printed next to each box ('must contain @'). The GlobalKey<FormState> is the clerk's reference number for this particular sheet: when you hand it over (validate()), the clerk goes through every box, writes a red note under each one that fails, and tells you whether the sheet as a whole is acceptable. A TextEditingController is a carbon copy of one box that you can read or overwrite from outside, and a FocusNode is the pen: whichever box the pen is in receives the typing.
Minimal working example
import 'package:flutter/material.dart';
class EmailForm extends StatefulWidget {
const EmailForm({super.key});
@override
State<EmailForm> createState() => _EmailFormState();
}
class _EmailFormState extends State<EmailForm> {
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) return 'Enter your email';
if (!value.contains('@')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Submitting')),
);
}
},
child: const Text('Submit'),
),
],
),
);
}
}- L11The key is typed GlobalKey<FormState>, not GlobalKey<_EmailFormState>. It lives in State so it is created once.
- L16Passing the key to Form is what makes _formKey.currentState resolve to this form's FormState.
- L22validator receives the current text (nullable). It runs when validate() is called, or automatically depending on autovalidateMode.
- L25Returning null means 'valid'. Any String is shown as the field's error text.
- L31validate() runs every validator in the form, rebuilds the fields to show errors, and returns true only if all passed.
Use cases
- Sign-up, login and checkout screens where several fields must be checked together before anything is sent to a server.
- Settings or profile editors where you want to validate on blur or as the user types, and reset the whole group with one call.
- Any field whose value must be read or changed from outside (pre-fill from a draft, clear after submit, mirror into a search query) via a TextEditingController.
- Multi-field flows where the keyboard's next/done actions should move focus in a defined order using FocusNodes.
Benefits
- One validate() call covers every field, and the error UI (red helper text, border colour) comes for free from TextFormField's InputDecoration.
- Validation logic sits next to the field that it concerns, which keeps rules local and easy to test.
- Controllers and FocusNodes work with plain TextField too, so the same patterns apply outside of a Form.
Limitations
- Validators are synchronous: checking a username against a server has to happen outside the validator (for example in the submit handler) and then be surfaced through state.
- FormState only knows about FormField descendants; a plain TextField inside a Form is not validated or saved.
- Cross-field rules (password == confirm) need access to the other field's controller or saved value; the validator only receives its own text.
- GlobalKeys are comparatively expensive and must be unique in the tree; do not create one per build or share one between two Forms.
Alternatives and trade-offs
- TextField + onChanged with state held by the parent — A single search box or filter where there is nothing to 'submit' and no error text to show.
- Form.of(context) — A deeply nested button needs to validate the enclosing Form without threading a GlobalKey through the tree.
- A form/state package (for example a ChangeNotifier-backed form model) — Many screens share the same validation rules or the form state must outlive the widget tree.
Creating the GlobalKey or controller inside build()
Problem: build() can run many times. A new GlobalKey<FormState>() on each build detaches the previous FormState, and a new TextEditingController wipes what the user typed and leaks the old one.
Fix: Declare them as final fields of the State class (or create them in initState) and dispose controllers and focus nodes in dispose().
@override
Widget build(BuildContext context) {
final formKey = GlobalKey<FormState>(); // new key each rebuild
final controller = TextEditingController(); // leaks, loses text
return Form(key: formKey, child: TextFormField(controller: controller));
}final _formKey = GlobalKey<FormState>();
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) =>
Form(key: _formKey, child: TextFormField(controller: _controller));Submitting without calling validate()
Problem: Writing a validator does not stop a submit. If the button handler reads controller.text and sends it, invalid data goes out and the error text never appears.
Fix: Gate the submit on _formKey.currentState!.validate(). If you also use onSaved callbacks, call save() after validate() returned true.
onPressed: () {
api.register(_emailController.text); // sent even when invalid
}onPressed: () {
if (!_formKey.currentState!.validate()) return;
api.register(_emailController.text);
}Forgetting to return null from the validator
Problem: A validator whose last branch falls through returns null implicitly only if its return type allows it; a stray return of the input value or an empty string makes every field look invalid ('' is a non-null String).
Fix: End every validator with an explicit `return null;` and never return '' to mean 'ok'.
Using AutovalidateMode.always on a fresh form
Problem: With always, every field shows its error text the moment the screen opens, before the user has typed anything, which reads as the app shouting at them.
Fix: Use AutovalidateMode.onUserInteraction (errors appear after the user edits a field) or onUnfocus (after the field loses focus), and keep disabled for forms that only validate on submit.
This page is Boring Doc’s independent explanation. For the complete specification use the official pages below (content © The Flutter Authors, CC BY 3.0; code samples BSD). Dates show when the explanation was last checked and when the official page last changed.
- https://docs.flutter.dev/cookbook/forms/validation
- Last checked
- Oct 21, 2026
- Source last updated
- 3 weeks ago
- Last synchronized
- 3 weeks ago
- https://docs.flutter.dev/cookbook/forms/text-input
- Last checked
- Oct 21, 2026
- Source last updated
- 3 weeks ago
- Last synchronized
- 3 weeks ago
- https://docs.flutter.dev/cookbook/forms/retrieve-input
- Last checked
- Oct 21, 2026
- Source last updated
- 3 weeks ago
- Last synchronized
- 3 weeks ago
- https://docs.flutter.dev/cookbook/forms/text-field-changes
- Last checked
- Oct 21, 2026
- Source last updated
- 3 weeks ago
- Last synchronized
- 3 weeks ago
- https://docs.flutter.dev/cookbook/forms/focus
- Last checked
- Oct 21, 2026
- Source last updated
- 3 weeks ago
- Last synchronized
- 3 weeks ago
- https://docs.flutter.dev/learn/pathway/tutorial/user-input
- Last checked
- Oct 21, 2026
- Source last updated
- 3 weeks ago
- Last synchronized
- 3 weeks ago
- FormState class API reference Flutter 3.47.7https://api.flutter.dev/flutter/widgets/FormState-class.html
- Last checked
- Oct 21, 2026
AI_PROVIDER (for example to openai-compatible with a local Ollama or LM Studio endpoint) to enable grounded explanations. Everything else on this page works without an AI provider.