Newsletter
Subscribe to our Flutter newsletter!
By submitting your email you agree to receive the content requested and to LeanCode's Privacy Policy
Home
Glossary

Forms in Flutter

Forms in Flutter are built with the Form widget, which groups FormField-based inputs like TextFormField under one GlobalKey<FormState> for shared validation, saving and resetting. Form state – field values, validation errors, pending async checks and whether submit is allowed – lives either inside the widget tree, as Form keeps it, or in a controller the widgets listen to.

A Form on its own renders nothing visually; it's a coordination layer around whatever fields it contains, and it covers synchronous, per-field validation. Forms with server-side checks, rules that read other fields, or sections added at runtime are usually moved into a controller layer, hand-written on ChangeNotifier or taken from a package such as advanced_forms, LeanCode's open-source form controller library.

Key facts at a glance

  • Form and TextFormField are part of the core Material library and need no package.
  • GlobalKey<FormState> is how a form is validated, saved or reset from outside the field widgets; FormState.validate() returns true only when every field's validator returns null.
  • Form keeps state in each field's State object, which lives as long as that widget's place in the tree; a controller keeps it in plain Dart objects, so it outlives the screen and can be unit-tested without a widget tree.
  • A typed field controller pairs a value type with an error type, so an email field, a checkbox and a dropdown expose the same validate, reset and error API.
  • With a controller, validation timing is a form-level decision – on submit, on every keystroke, on unfocus, or after the first unfocus – rather than a per-widget autovalidateMode.

How do I build a form in Flutter?

Wrap the fields in a Form, hold a GlobalKey<FormState>, and call validate() on submit.

import 'package:flutter/material.dart';

class SignupForm extends StatefulWidget {
  const SignupForm({super.key});

  @override
  State<SignupForm> createState() => _SignupFormState();
}

class _SignupFormState extends State<SignupForm> {
  final _formKey = GlobalKey<FormState>();
  String _email = '';
  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: const InputDecoration(labelText: 'Email'),
            validator: (value) =>
                (value == null || !value.contains('@')) ? 'Enter a valid email' : null,
            onSaved: (value) => _email = value ?? '',
          ),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                _formKey.currentState!.save();
                debugPrint('Submitting $_email');
              }
            },
            child: const Text('Sign up'),
          ),
        ],
      ),
    );
  }
}

validate() returns true only if every field's validator returns null; save() should run only after that check passes.

How do I manage form state outside the widget tree?

Declare the fields in a form controller, register them once, and let one builder per field listen to its own controller. The shape below is advanced_forms; a hand-written controller on ChangeNotifier follows the same pattern with more code.

import 'package:advanced_forms/advanced_forms.dart';

class SignupFormController extends AdvancedFormController {
  SignupFormController() : super(validationMode: ValidationMode.afterFirstUnfocus) {
    registerFields([email, password]);
  }

  final email = AdvancedTextFieldController(
    validator: filled('Email is required'),
    asyncValidation: AsyncValidation(
      validator: (value) async =>
          await api.isEmailTaken(value) ? 'This email is already taken' : null,
    ),
  );
  final password = AdvancedTextFieldController(
    validator: atLeastLength(8, 'At least 8 characters'),
  );

  Future<void> submit() async {
    if (await validate()) {
      // send it
    }
  }
}

The async validator runs debounced and only after the sync one passed; validate() awaits it before answering. On the widget side, one AdvancedFieldBuilder per field binds a controller to a TextFormField, so a keystroke rebuilds that field and nothing else.

Form widget vs. general state management vs. form controllers

Form + setStateprovider / bloc / riverpodadvanced_forms
Setup
NoneApp-wide state solutionOne pub.dev package, no framework on top
Values and errors
Per field, in widget StateModeled by hand per formTyped field controllers with built-in error state
Async validation
Hand-written, easy to raceHand-writtenDebounced, with timeout, failure handling and cached answers
Cross-field rules
Shared TextEditingControllerHand-writtensubscribeToFields re-runs a validator when its dependencies change
Dynamic sections
Fields toggled in the tree; a removed field loses its stateHand-writtenSubforms attached and detached at runtime, values kept
Testing
Widget testsUnit tests of your modelUnit tests of the controller, no widget tree
Best for
A few fields with sync rulesApps that already model forms as stateSign-up, checkout, multi-step wizards

Verdict: keep Form while the rules are synchronous and per field. Once a form has a server-side check or a rule between fields, move the state into a controller. A general state management package can hold that model, but you write every piece of it; advanced_forms provides the field controllers, validators, validation modes and submit state, plugs into the state management you already use, and is AI-ready in a concrete sense – it ships an Agent Skill (dart run skills@ get) that gives coding agents such as Claude Code the API, so the forms they generate use it correctly.

Best practices

  1. Keep one GlobalKey<FormState> per Form, and validate() before save() – reusing a key across forms causes assertion errors, and saving unvalidated data submits garbage.
  2. Use TextFormField, not TextField, inside a Form – only the former takes part in FormState.validate() – and dispose any TextEditingControllers in the enclosing State's dispose().
  3. Move form state into a controller before the form outgrows Form – the first server-side check or cross-field rule hand-wired into validator callbacks is the usual point.
  4. Pick one validation mode for the whole form and override it only on the field that needs a different one; bind the submit button to canSubmit and decide with validate().
  5. Debounce and time out every server-side check, and treat a failed request as a distinct state, not as "valid".

Common mistakes

  • Mixing plain TextField into a Form expecting validation to work. TextField has no validator; only TextFormField participates in FormState.validate().
  • Awaiting a network call inside a synchronous validator. validator must return immediately; async checks need their own debounced path, or two fast keystrokes race and the stale answer wins.
  • Validating a confirm-password field without re-running it when the password changes. Cross-field rules must subscribe to the fields they read, or the error goes stale.
  • Rebuilding the entire form on every keystroke by putting form state too high in the widget tree, or in one object that notifies everything. One listener per field keeps rebuilds local.
  • Disposing field controllers one by one and missing some. A form controller that owns its fields disposes them in one place.

Learn more

UI Testing in Flutter by LeanCode

The Role of UI Testing in Large-Scale Applications

We dive into the topic of UI testing in Flutter. Read more about automated UI tests' benefits and available solutions and why UI testing plays a crucial role, especially in large-scale applications.

12 Flutter & Dart Code Hacks
by LeanCode

24 Flutter & Dart Code Hacks & Best Practices – How to Write Better Code?

In this article, we’re sharing LeanCode’s 24 practical Flutter and Dart patterns that help you write less boilerplate, make your code cleaner, and catch mistakes earlier. Apply these patterns and you'll find yourself coding faster, communicating more clearly with your teammates, and spending less time debugging issues that the compiler could have caught.

Flutter architecture by LeanCode

Feature-Based Flutter App Architecture - LeanCode

Read about the LeanCode approach to Flutter architecture. We highlight some of the design decisions that should be made when developing a feature in mobile apps. This includes our approach to dependency injection, state management, widget lifecycle, and data fetching.