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.
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.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.validate, reset and error API.autovalidateMode.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.
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 + setState | provider / bloc / riverpod | advanced_forms |
|---|---|---|
Setup | ||
| None | App-wide state solution | One pub.dev package, no framework on top |
Values and errors | ||
| Per field, in widget State | Modeled by hand per form | Typed field controllers with built-in error state |
Async validation | ||
| Hand-written, easy to race | Hand-written | Debounced, with timeout, failure handling and cached answers |
Cross-field rules | ||
| Shared TextEditingController | Hand-written | subscribeToFields re-runs a validator when its dependencies change |
Dynamic sections | ||
| Fields toggled in the tree; a removed field loses its state | Hand-written | Subforms attached and detached at runtime, values kept |
Testing | ||
| Widget tests | Unit tests of your model | Unit tests of the controller, no widget tree |
Best for | ||
| A few fields with sync rules | Apps that already model forms as state | Sign-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.
GlobalKey<FormState> per Form, and validate() before save() – reusing a key across forms causes assertion errors, and saving unvalidated data submits garbage.TextFormField, not TextField, inside a Form – only the former takes part in FormState.validate() – and dispose any TextEditingControllers in the enclosing State's dispose().Form – the first server-side check or cross-field rule hand-wired into validator callbacks is the usual point.canSubmit and decide with validate().TextField into a Form expecting validation to work. TextField has no validator; only TextFormField participates in FormState.validate().validator must return immediately; async checks need their own debounced path, or two fast keystrokes race and the stale answer wins.
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.

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.

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.