skip to content

In Flutter, how do a Form, its GlobalKey<FormState> and TextFormField validators work together when the user taps Submit?

level: juniorimportance: must knowfreq 72%

answer

  1. one key, one FormState
  2. fields register with the nearest Form
  3. validator returns a message or null
  4. validate() returns bool and rebuilds errors
  5. save() fires every onSaved

basics

~20 s

A Form keyed with a GlobalKey<FormState> tracks its TextFormField descendants. On submit, validate() runs every validator (null means valid, a string becomes the error), returns true only if all pass, and save() then calls each onSaved.

solid answer

~30 s

`Form` is a stateful container: every `FormField` below it, `TextFormField` included, registers its `FormFieldState` with the enclosing `FormState`. You reach that state through a `GlobalKey<FormState>` created once as a field of your `State`, and on submit call `_formKey.currentState!.validate()`. It runs each field's `validator`, a synchronous `String? Function(String?)` where `null` means valid and a string becomes that field's error text, rebuilds the fields so the errors appear, and returns `true` only when none failed. `validate()` saves nothing: call `save()` afterwards to fire every `onSaved`, or read your controllers. `reset()` puts each field back to its initial value and clears its error.

code

dart · 54 lines
dart
import 'package:flutter/material.dart';

class VehicleForm extends StatefulWidget {
  const VehicleForm({super.key, required this.onSubmit});

  final void Function(String plate, String owner) onSubmit;

  @override
  State<VehicleForm> createState() => _VehicleFormState();
}

class _VehicleFormState extends State<VehicleForm> {
  final _formKey = GlobalKey<FormState>();
  final _plateFormat = RegExp(r'^[A-Z0-9]{2,8}$');
  String _plate = '';
  String _owner = '';

  void _submit() {
    final form = _formKey.currentState!;
    if (!form.validate()) return;
    form.save();
    widget.onSubmit(_plate, _owner);
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: const InputDecoration(labelText: 'Plate number'),
            validator: (value) {
              final plate = value?.trim() ?? '';
              if (plate.isEmpty) return 'Enter the plate number';
              if (!_plateFormat.hasMatch(plate)) {
                return 'Use 2-8 capital letters or digits';
              }
              return null;
            },
            onSaved: (value) => _plate = value?.trim() ?? '',
          ),
          TextFormField(
            decoration: const InputDecoration(labelText: 'Owner name'),
            validator: (value) =>
                (value == null || value.trim().isEmpty) ? 'Enter the owner name' : null,
            onSaved: (value) => _owner = value?.trim() ?? '',
          ),
          FilledButton(onPressed: _submit, child: const Text('Register')),
        ],
      ),
    );
  }
}

go deeper

for a junior

Recall the contract: a GlobalKey<FormState>, a validator that returns null for valid or a message for invalid, validate() returning a bool, then save() for onSaved.

for a middle

Explain registration: fields join the nearest FormState as they build, validate() runs every validator and rebuilds, and reset() restores initial values rather than emptying fields.

for a senior

Show where forms go wrong in production: a key created in build, validators with side effects, unmounted fields that never validate, and submit handlers that save before validating.

for a principal

Discuss when Form plus per-field validators stops scaling and validation belongs in a model or state object that the widgets only display.

## The three pieces Flutter's form support is built from three cooperating parts: - **`Form`** is a `StatefulWidget` whose state, **`FormState`**, keeps a set of every `FormFieldState` registered beneath it. It adds no layout of its own; you put a `Column` or a `ListView` inside it. - **`FormField<T>`** is the generic field: it holds a value of type `T`, an optional `validator`, an `onSaved` callback and an `initialValue`. **`TextFormField`** is a `FormField<String>` that wraps a Material `TextField` and passes the field's current error into the `InputDecoration`. - **`GlobalKey<FormState>`** is how code *outside* the form's subtree (typically the submit button's handler in the same `build` method) reaches the `FormState`. Create it once, as a field of your `State`; a key created inside `build()` is a new key on every rebuild, so the form's state and everything typed into it are thrown away. A field registers with the nearest enclosing `Form` each time it builds and unregisters when it is deactivated, so the set always matches the fields that are currently mounted. ## What validate() actually does When the user taps Submit and you call `_formKey.currentState!.validate()`: 1. The form marks itself as interacted with and schedules a rebuild. 2. It walks every registered field and calls that field's `validate()`, which runs the `validator` with the field's current value (a `forceErrorText`, if one is set, is used instead of the validator). 3. Each result is stored as the field's `errorText`. A `null` result means valid; **any** non-null string, even `''`, means invalid. 4. The fields rebuild; `TextFormField` copies the error into its decoration, so the message appears under the input. 5. `validate()` returns `true` only if no field ended up with an error. The validator's type is `FormFieldValidator<T>`, which is `String? Function(T? value)`. It is synchronous: it cannot `await` anything, and under some autovalidate modes it runs on every rebuild, so it must be cheap and free of side effects. ## Saving, resetting and the other FormState methods | Method | What it does | |---|---| | `validate()` | Runs every validator, shows the errors, returns `bool` | | `validateGranularly()` | Same, but returns the `Set<FormFieldState<Object?>>` of invalid fields | | `save()` | Calls every field's `onSaved(value)`; runs no validation | | `reset()` | Restores each field's initial value, clears errors, fires `Form.onChanged` | | `clearError()` | Clears every error but keeps the values (Flutter 3.44+) | The usual submit handler is therefore *validate, then save, then use the values*. `onSaved` is a convenient place to copy each value into a model object; if you already hold `TextEditingController`s you can skip `save()` and read `controller.text` instead. `reset()` is subtler than "clear the form". Each field goes back to its `initialValue`; a `TextFormField` given a controller goes back to the text the controller held when the field was first built. The field also forgets that the user touched it, so autovalidation stops until the next edit. ## Fields that depend on each other Whenever any field's value changes, `FormState` notifies `Form.onChanged` and rebuilds its scope, which rebuilds every registered field. The framework does this deliberately, for **interdependent fields**: a validator may read another field's value, for instance through that field's `TextEditingController`, and it will be re-run on the next validation pass with the fresh value. Typical cases: - a "confirm plate number" field whose validator compares its value with the plate field's controller; - a registration expiry date that must fall after the issue date entered above it; - a field that is required only when a checkbox elsewhere in the form is ticked. Because all fields rebuild on every change, validators must stay cheap: no parsing of large data, no I/O, just checks on values already in memory. ## A vehicle-registration example The code example below registers a vehicle: the plate number must be 2 to 8 capital letters or digits, and the owner's name must not be empty. The button calls `validate()`; only when it returns `true` does the handler call `save()` and pass the saved values on. ## Mistakes interviewers listen for - Returning `true`/`false` from a validator, or returning `''` for "valid": the contract is `null` for valid. - Expecting `validate()` to call `onSaved`; it never does. - Calling `Form.of(context)` with the `context` of the widget that *builds* the `Form`: that context sits above the form, so the lookup fails. Use the `GlobalKey`, or a `Builder` below the form. - Putting a network call in a validator (it cannot be awaited and may run many times). - Creating the `GlobalKey<FormState>` inside `build()`.

  • Why reach the FormState through a GlobalKey instead of Form.of(context) in the submit handler?
    `Form.of(context)` looks for the nearest `Form` *above* the given context. The submit handler usually runs in the same `build` method that creates the `Form`, so its `context` sits above the form and the lookup fails. A `GlobalKey<FormState>` held as a `State` field reaches the form from anywhere; `Form.of` works only from a context below the form, such as inside a `Builder`.
  • What does validateGranularly() give you that validate() does not?
    `validateGranularly()` runs the same validation but returns a `Set<FormFieldState<Object?>>` of the fields that failed instead of a `bool`. That lets you act on specific fields, for example scrolling the first invalid one into view with `Scrollable.ensureVisible(field.context)`. Like `validate()`, it marks the form as interacted with and rebuilds it.
  • After FormState.reset(), what does a TextFormField created with initialValue: 'AB123' show?
    It shows `AB123` again with no error. `reset()` restores every field's initial value, clears its error and its has-interacted flag, and calls `Form.onChanged`. `TextFormField` also writes the initial text back into its controller and calls its own `onChanged`. Only a form whose `autovalidateMode` is `always` revalidates straight away.

saying these in an interview costs you the question

  • A validator returns true when the value is valid.
  • Returning an empty string from a validator marks the field as valid.
  • validate() also calls onSaved, so save() is never needed.
  • Creating the GlobalKey<FormState> inside build() is harmless.
  • A validator can await a server check before returning.