skip to content

Text Fields & Validators

A Form keyed by GlobalKey<FormState> validates its TextFormField children, while TextEditingController, InputDecoration and formatters shape each field. Interviewers probe validation timing.

part ofFlutteroverview, primer and where to startread it →
on this pageshow

explore

questions

6

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.
open as a page

In Flutter, where should a TextFormField's TextEditingController be created and disposed, and what breaks if build() creates it?

level: middleimportance: must knowfreq 62%

basics

~20 s

Create a TextEditingController once per State, as a field initializer or in initState, and call its dispose() in dispose(). Created inside build(), every rebuild hands the field a fresh empty controller, losing the typed text, and the old ones are never disposed.

open as a page

In Flutter, how do you build a password TextFormField with a show/hide toggle that password managers can fill and save?

level: juniorimportance: should knowfreq 44%

basics

~20 s

Drive obscureText from a bool in State and flip it with setState from an IconButton in InputDecoration.suffixIcon. Add autofillHints such as AutofillHints.username and AutofillHints.password, and wrap the credential fields in an AutofillGroup so the platform can offer and save them.

open as a page

In Flutter, how does each AutovalidateMode value change when Form and TextFormField validators run, and which suits a registration form?

level: middleimportance: should knowfreq 48%

basics

~20 s

AutovalidateMode decides when validators run without an explicit validate(): disabled (the default) waits for it; always runs on every build; onUserInteraction after an edit; onUnfocus when a field loses focus; onUserInteractionIfError re-checks only fields already showing an error.

open as a page

For a Flutter TextFormField that takes vehicle plate numbers, what do keyboardType, inputFormatters and the validator each guarantee?

level: middleimportance: should knowfreq 38%

basics

~20 s

keyboardType only asks the soft keyboard for a layout; inputFormatters rewrite every user edit, in list order, before the controller and onChanged see it; the validator judges the complete value when validation runs. Only the validator can reject an incomplete or malformed plate.

open as a page

In a Flutter vehicle-registration form, how do you show a server's "plate already registered" error on a TextFormField when validators are synchronous?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Validate locally, submit, and when the server rejects the plate, store its message in State and pass it as the field's forceErrorText (Flutter 3.24+); clear it when the user edits. Never put the network call inside a validator.

open as a page