Skip to content
Blog

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. A GlobalKey<FormState>() must be created exactly once. If you made this a StatelessWidget, you’d have to store the key somewhere — and recreating it every build() is expensive and breaks state identity.
  • It’s GlobalKey<FormState>, not GlobalKey<_MyCustomFormState>. The key types the form’s state, which Flutter creates automatically when it builds the Form.

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:

  1. Runs every field’s validator() in the subtree.
  2. If all return null, returns true.
  3. 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 — only validate() 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 _formKey in build() — the form loses state every frame. Declare it as a field on State.
  • Empty-string vs null — validators receive null only if the field was never edited in some paths; check both.
  • Validating before first build — _formKey.currentState is 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, call setState.