Steven's Knowledge

Forms & Validation

Flutter forms — Form/TextFormField, validators, async validation, focus management, submission flow, state management, accessibility, testing

Forms & Validation

Production-grade form handling in Flutter. The guiding principles: validate close to the user, keep business rules in the domain layer, and never block the UI while you check.

The Form Primitives

Three pieces work together. A Form widget owns a FormState; each FormField (most often TextFormField) registers itself with the nearest Form; a GlobalKey<FormState> gives you the handle to drive validation and saving.

class LoginForm extends StatefulWidget {
  const LoginForm({super.key});
  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  String _email = '';

  void _submit() {
    final form = _formKey.currentState!;
    if (!form.validate()) return; // runs every field's validator
    form.save();                  // triggers every field's onSaved
    // _email now holds the saved value
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: const InputDecoration(labelText: 'Email'),
            validator: (v) => (v == null || v.isEmpty) ? 'Required' : null,
            onSaved: (v) => _email = v ?? '',
          ),
          ElevatedButton(onPressed: _submit, child: const Text('Sign in')),
        ],
      ),
    );
  }
}

FormState exposes three methods:

MethodWhat it doesWhen to call
validate()Runs all field validators, returns true if all pass, repaints errorsOn submit, or imperatively
save()Calls every field's onSaved callbackAfter validate() succeeds
reset()Restores every field to its initialValue and clears errors"Clear form" actions

AutovalidateMode

autovalidateMode controls when validators run automatically, on both Form and individual FormFields.

ModeBehaviourUse it for
disabled (default)Only validates when you call validate()Short forms where submit-time feedback is fine
onUserInteractionValidates a field once the user has interacted with it, then on every changeThe right default for most forms — no errors before the user types, instant feedback after
alwaysValidates on every build, including first paintAlmost never — see Anti-Patterns
Form(
  key: _formKey,
  autovalidateMode: AutovalidateMode.onUserInteraction,
  child: ...,
)

onUserInteraction is the UX sweet spot. The user gets immediate, per-field feedback as they correct mistakes, but is never greeted by a wall of red errors on a form they have not touched yet. Set it on the Form so it cascades to all children.

Controllers vs initialValue

A TextFormField can manage its own text two ways. Use initialValue for a simple default you read back through onSaved. Reach for a TextEditingController only when you need to read, mutate, or react to the text outside the normal save flow — pre-filling from async data, clearing a field on a button tap, mirroring the value into another widget, or driving selection.

// Simple: no controller needed
TextFormField(
  initialValue: user.displayName,
  onSaved: (v) => _name = v ?? '',
)

// Controller: needed because we read/clear it imperatively
final _searchController = TextEditingController();

TextFormField(
  controller: _searchController,
  decoration: InputDecoration(
    suffixIcon: IconButton(
      icon: const Icon(Icons.clear),
      onPressed: _searchController.clear,
    ),
  ),
)

Never set both controller and initialValue — Flutter throws an assertion. With a controller, seed the initial text via TextEditingController(text: ...) instead.

Always dispose controllers and FocusNodes

TextEditingController and FocusNode hold listeners and native resources. Forgetting to dispose them leaks memory and can fire callbacks on a dead widget.

class _ProfileFormState extends State<ProfileForm> {
  final _nameController = TextEditingController();
  final _nameFocus = FocusNode();

  @override
  void dispose() {
    _nameController.dispose();
    _nameFocus.dispose();
    super.dispose();
  }
}

The flutter_lints rule set does not catch this — enforce it in review, or move ownership into a state-management object whose disposal is automatic (see State Management).

Validators

A validator is String? Function(T? value): return null when valid, or an error message string when not.

Keep them small and reusable

Define validators once as pure functions and reuse them across forms. This also keeps them testable in isolation.

class Validators {
  static String? required(String? v) =>
      (v == null || v.trim().isEmpty) ? 'Required' : null;

  static String? email(String? v) {
    if (v == null || v.isEmpty) return null; // let `required` own emptiness
    final re = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
    return re.hasMatch(v) ? null : 'Enter a valid email';
  }

  static String? Function(String?) minLength(int n) =>
      (v) => (v != null && v.length >= n) ? null : 'At least $n characters';
}

Composing multiple validators

A field often needs several rules. Compose them into one validator that returns the first failure:

String? Function(String?) compose(List<String? Function(String?)> validators) {
  return (value) {
    for (final validate in validators) {
      final error = validate(value);
      if (error != null) return error;
    }
    return null;
  };
}

TextFormField(
  validator: compose([Validators.required, Validators.email]),
)

Cross-field validation

For rules that depend on another field — confirm-password, date ranges — read the other field's value directly. Keep the controllers in scope, or store the canonical value in your state object.

final _password = TextEditingController();

// ... password field uses _password as its controller ...

TextFormField(
  decoration: const InputDecoration(labelText: 'Confirm password'),
  validator: (v) => v == _password.text ? null : 'Passwords do not match',
)

Localize your messages. The literals above are illustrative. In a shipping app, return AppLocalizations.of(context).passwordsDoNotMatch so errors are translated — see i18n.

Async Validation

validator is synchronous, so it cannot directly await a server call. The pattern: validate the value asynchronously in your state layer, debounce it, surface a pending state, store the result, and have the synchronous validator simply read that stored result.

class _SignupFormState extends State<SignupForm> {
  Timer? _debounce;
  String? _usernameError;
  bool _checking = false;

  void _onUsernameChanged(String value) {
    _debounce?.cancel();
    setState(() => _checking = true);
    _debounce = Timer(const Duration(milliseconds: 400), () async {
      final taken = await api.isUsernameTaken(value);
      if (!mounted) return; // guard the async gap
      setState(() {
        _checking = false;
        _usernameError = taken ? 'Username is taken' : null;
      });
      _formKey.currentState?.validate(); // re-run to paint the result
    });
  }

  @override
  void dispose() {
    _debounce?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      decoration: InputDecoration(
        labelText: 'Username',
        suffixIcon: _checking
            ? const Padding(
                padding: EdgeInsets.all(12),
                child: SizedBox(
                  width: 16, height: 16,
                  child: CircularProgressIndicator(strokeWidth: 2),
                ),
              )
            : null,
      ),
      onChanged: _onUsernameChanged,
      validator: (_) => _usernameError, // reads the cached async result
    );
  }
}

The UI never blocks: typing stays smooth, a spinner shows progress, and the network call only fires after the user pauses. Always re-check mounted after the await (see the BuildContext rules in Best Practices).

Focus & Keyboard Management

Good forms move the user through fields without making them reach for the screen.

final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();

TextFormField(
  focusNode: _emailFocus,
  keyboardType: TextInputType.emailAddress,
  textInputAction: TextInputAction.next, // shows "Next" on the keyboard
  onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
),
TextFormField(
  focusNode: _passwordFocus,
  obscureText: true,
  textInputAction: TextInputAction.done, // shows "Done"
  onFieldSubmitted: (_) => _submit(),
),

Input type and formatters

keyboardType picks the right soft keyboard; inputFormatters constrain or transform what the user can type.

TextFormField(
  keyboardType: TextInputType.number,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    LengthLimitingTextInputFormatter(11),
    _PhoneMaskFormatter(), // custom TextInputFormatter that inserts spaces
  ],
)
NeedTool
Email keyboardTextInputType.emailAddress
Numeric keypadTextInputType.number / TextInputType.phone
Digits onlyFilteringTextInputFormatter.digitsOnly
Block charactersFilteringTextInputFormatter.deny(RegExp(...))
Length capLengthLimitingTextInputFormatter(n)
Phone / currency maskCustom TextInputFormatter (or mask_text_input_formatter)
Hide passwordobscureText: true

Scrolling the focused field into view

When the keyboard covers the active field, wrap the form in a scroll view. Flutter auto-scrolls the focused TextField into view inside a SingleChildScrollView, and Scaffold resizes for the keyboard via resizeToAvoidBottomInset (on by default).

Scaffold(
  body: SingleChildScrollView(
    padding: const EdgeInsets.all(16),
    child: Form(key: _formKey, child: ...),
  ),
)

Submission Flow

A robust submit handler disables the button, shows progress, handles success, and maps server errors back onto the right fields.

bool _submitting = false;
final Map<String, String?> _serverErrors = {};

Future<void> _submit() async {
  if (!_formKey.currentState!.validate()) return;
  _formKey.currentState!.save();
  setState(() => _submitting = true);
  try {
    await api.register(_email, _password);
    if (!mounted) return;
    Navigator.of(context).pushReplacementNamed('/home');
  } on FieldValidationException catch (e) {
    // e.errors == {'email': 'Already registered'}
    if (!mounted) return;
    setState(() => _serverErrors.addAll(e.errors));
    _formKey.currentState!.validate(); // repaint with server messages
  } finally {
    if (mounted) setState(() => _submitting = false);
  }
}

The submit button reflects the loading state, and each field's validator consults _serverErrors:

ElevatedButton(
  onPressed: _submitting ? null : _submit,
  child: _submitting
      ? const SizedBox(
          width: 18, height: 18,
          child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
        )
      : const Text('Create account'),
)

TextFormField(
  validator: (v) => Validators.email(v) ?? _serverErrors['email'],
  onChanged: (_) => _serverErrors['email'] = null, // clear once edited
)

Server-side validation is not optional. Client rules are a UX convenience; the server is the source of truth. Always map field-level server errors back onto the corresponding inputs so the user sees them inline rather than as an opaque "something went wrong" toast.

Form State with State Management

For short forms, local State is fine. For complex, multi-step, or shared-across-screens forms, move the form's state — values, validation status, submission status, server errors — into your state-management layer. This keeps the widget thin and makes the flow testable.

// Riverpod sketch — the notifier owns all form state
class SignupState {
  final String email;
  final String? emailError;
  final bool submitting;
  const SignupState({this.email = '', this.emailError, this.submitting = false});
  SignupState copyWith({String? email, String? emailError, bool? submitting}) =>
      SignupState(
        email: email ?? this.email,
        emailError: emailError,
        submitting: submitting ?? this.submitting,
      );
}

class SignupNotifier extends Notifier<SignupState> {
  @override
  SignupState build() => const SignupState();

  void setEmail(String v) => state = state.copyWith(email: v, emailError: null);

  Future<bool> submit() async {
    state = state.copyWith(submitting: true);
    final result = await ref.read(authRepoProvider).register(state.email);
    state = state.copyWith(submitting: false, emailError: result.emailError);
    return result.ok;
  }
}

Multi-step wizards especially benefit: each step is a separate page reading from one notifier, so navigating back and forth never loses entered data. See State Management and the layering guidance in Architecture.

Raw Form vs. form packages

Raw Form + TextFormFieldflutter_form_builderreactive_forms
Mental modelImperative, GlobalKey-drivenDeclarative field widgets, named fieldsReactive (FormControl/FormGroup), RxDart-style
BoilerplateHigher per fieldLow — many prebuilt fieldsLow once you learn the model
Built-in validatorsNone (you write them)Bundled (FormBuilderValidators)Bundled
Cross-field / dynamicManualSupportedFirst-class, reactive streams
Best forSimple to medium forms, full controlLots of standard fields fastLarge, dynamic, reactive forms

Start with the raw Form — it has zero dependencies and is enough for the vast majority of screens. Adopt a package only when boilerplate or reactive requirements justify it.

Accessibility

Forms are where screen-reader support matters most. The essentials:

  • Always supply a label. InputDecoration(labelText: ...) is announced by VoiceOver/TalkBack; never rely on a bare hintText, which disappears once typing starts.
  • Errors are announced. A TextFormField's errorText is exposed to the accessibility tree automatically — another reason to use the framework's validation rather than rolling your own error widget.
  • Move focus to the first error on a failed submit so screen-reader users land on the problem.
  • Tap targets ≥ 48x48 dp for checkboxes, switches, and submit buttons.
  • Wrap non-standard inputs in Semantics with the right label and textField: true.
// Announce a form-level status to the screen reader after submit
SemanticsService.announce('Form submitted successfully', TextDirection.ltr);

Full coverage — focus order, live regions, contrast — is on the Accessibility page.

Testing Forms

A widget test should enter text, tap submit, and assert that validation errors render and the submit handler runs.

testWidgets('shows error and blocks submit when email is empty', (tester) async {
  var submitted = false;
  await tester.pumpWidget(MaterialApp(
    home: Scaffold(body: LoginForm(onSubmit: () => submitted = true)),
  ));

  // Tap submit with empty fields
  await tester.tap(find.text('Sign in'));
  await tester.pump();

  expect(find.text('Required'), findsWidgets); // error painted
  expect(submitted, isFalse);                  // handler not called
});

testWidgets('submits when fields are valid', (tester) async {
  var submitted = false;
  await tester.pumpWidget(MaterialApp(
    home: Scaffold(body: LoginForm(onSubmit: () => submitted = true)),
  ));

  await tester.enterText(find.byKey(const Key('email')), 'a@b.com');
  await tester.enterText(find.byKey(const Key('password')), 'secret123');
  await tester.tap(find.text('Sign in'));
  await tester.pump();

  expect(submitted, isTrue);
});

Give fields stable Keys so tests target them unambiguously. Keep pure validators in unit tests — they need no widget tree at all. More patterns on the Testing page.

Anti-Patterns

1. Not disposing controllers and FocusNodes

// WRONG: leaks the controller and its listeners every time the widget mounts
final _controller = TextEditingController();
// ...no dispose()

// CORRECT
@override
void dispose() {
  _controller.dispose();
  super.dispose();
}

2. Business validation logic inside the widget

Rules like "an order over $10,000 needs approval" belong in the domain layer, not in a validator closure. Keep widget validators about input shape (required, format, length); delegate business rules to your domain so they are reusable and unit-testable without a widget tree.

3. Validating only on submit for long forms

Making a user fill ten fields, hit submit, and then discover three errors is hostile UX. Use autovalidateMode: onUserInteraction so each field gives feedback as it is corrected.

4. Blocking the UI on async validation

// WRONG: validator cannot await, and you must never freeze the frame on a network call
validator: (v) => api.isUsernameTakenSync(v) ? 'Taken' : null,

// CORRECT: debounce off the UI thread, cache the result, read it synchronously
validator: (_) => _usernameError,

5. Ignoring server-side validation errors

Swallowing a 422 into a generic snackbar leaves the user guessing which field is wrong. Map server field errors back onto the inputs.

6. autovalidateMode: always

This paints errors on first build, before the user has typed anything — a form that opens covered in red. Reserve it for the rare case where pre-populated values genuinely need immediate validation; default to onUserInteraction.

7. Scattering form values across many places

Keep each value in exactly one source of truth — a controller, an onSaved target, or a state notifier — not duplicated across local fields, a provider, and a controller at once. Divergent copies drift out of sync and produce bugs that are painful to trace.

The form checklist before shipping: every controller and FocusNode disposed; autovalidateMode: onUserInteraction; validators pure and localized; async checks debounced and non-blocking; submit button disabled while in flight; server errors mapped to fields; labels present and errors announced; widget tests covering the empty-and-valid paths.

On this page