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:
| Method | What it does | When to call |
|---|---|---|
validate() | Runs all field validators, returns true if all pass, repaints errors | On submit, or imperatively |
save() | Calls every field's onSaved callback | After 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.
| Mode | Behaviour | Use it for |
|---|---|---|
disabled (default) | Only validates when you call validate() | Short forms where submit-time feedback is fine |
onUserInteraction | Validates a field once the user has interacted with it, then on every change | The right default for most forms — no errors before the user types, instant feedback after |
always | Validates on every build, including first paint | Almost 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
],
)| Need | Tool |
|---|---|
| Email keyboard | TextInputType.emailAddress |
| Numeric keypad | TextInputType.number / TextInputType.phone |
| Digits only | FilteringTextInputFormatter.digitsOnly |
| Block characters | FilteringTextInputFormatter.deny(RegExp(...)) |
| Length cap | LengthLimitingTextInputFormatter(n) |
| Phone / currency mask | Custom TextInputFormatter (or mask_text_input_formatter) |
| Hide password | obscureText: 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 + TextFormField | flutter_form_builder | reactive_forms | |
|---|---|---|---|
| Mental model | Imperative, GlobalKey-driven | Declarative field widgets, named fields | Reactive (FormControl/FormGroup), RxDart-style |
| Boilerplate | Higher per field | Low — many prebuilt fields | Low once you learn the model |
| Built-in validators | None (you write them) | Bundled (FormBuilderValidators) | Bundled |
| Cross-field / dynamic | Manual | Supported | First-class, reactive streams |
| Best for | Simple to medium forms, full control | Lots of standard fields fast | Large, 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 barehintText, which disappears once typing starts. - Errors are announced. A
TextFormField'serrorTextis 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
Semanticswith the rightlabelandtextField: 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.