Forms, Validation & User Input

The Form Widget & GlobalKey

15 min Lesson 1 of 12

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 FormField widgets under one coordinating parent
  • Exposing a FormState object that has validate(), save(), and reset() methods
  • Triggering validation on all descendant fields simultaneously when you call validate()
  • Calling the onSaved callback on every field when you call save()
  • Clearing all field values and error text when you call reset()
Note: The most common 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();
    }
  }
}
Warning: Never create a 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's validator callback. Returns true if all validators return null (meaning valid), or false if any returned an error string. Also displays error text below invalid fields.
  • save() — Calls the onSaved callback on every FormField descendant, allowing you to collect the current values into your own variables.
  • reset() — Resets every field to its initialValue and 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 when validate() 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).
Tip: 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.