Skip to content
Boring Doc
Boring Doc explanation Verified against Flutter 3.47.7 3 min readformsFormTextFormFieldvalidatorGlobalKey

Forms and validation

Inputs, validation, focus and submission.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
State transitionsLifecycle of a single TextFormField

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.

Where the analogy breaks: A paper clerk checks once; Flutter can re-check on every keystroke if you set autovalidateMode. And unlike paper, the form's memory lives in State objects: if the Form widget is removed from the tree (navigation, conditional rendering), its FormState and field states are discarded, and the key will point at nothing (currentState is null).

Minimal working example

A Form with one validated field and a submit button
dart
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'),
          ),
        ],
      ),
    );
  }
}
Expected: Tapping Submit with an empty field shows 'Enter your email' under the field. With text that lacks '@' it shows 'Enter a valid email'. With a valid address a snack bar appears.Flutter ≥ 3.0.0Build a form with validation (cookbook)
  1. L11The key is typed GlobalKey<FormState>, not GlobalKey<_EmailFormState>. It lives in State so it is created once.
  2. L16Passing the key to Form is what makes _formKey.currentState resolve to this form's FormState.
  3. L22validator receives the current text (nullable). It runs when validate() is called, or automatically depending on autovalidateMode.
  4. L25Returning null means 'valid'. Any String is shown as the field's error text.
  5. 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().

✗ Key and controller recreated every build
dart
@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));
}
✓ Owned by the State
dart
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.

✗ Validator exists but is never run
dart
onPressed: () {
  api.register(_emailController.text); // sent even when invalid
}
✓ validate() first
dart
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.

  • Build a form with validation Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/cookbook/forms/validation
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • Create and style a text field Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/cookbook/forms/text-input
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • Retrieve the value of a text field Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/cookbook/forms/retrieve-input
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • Handle changes to a text field Flutter 3.47.7 Explanation verified against current source
    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
  • Focus and text fields Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/cookbook/forms/focus
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • User input (learning pathway) Flutter 3.47.7 Explanation verified against current source
    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
  • https://api.flutter.dev/flutter/widgets/FormState-class.html
    Last checked
    Oct 21, 2026
Was this useful?
AI explanationAI-generated · grounded in cited sources
AI explanations are not configured
Set 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.