Forms and input
Text input is where most disposal leaks, un-localized strings, and jank enter a Flutter app. A form is a small state machine: fields hold text, a FormState validates them, and a ViewModel owns anything that touches the network or the clock. Keep those three responsibilities separate.
Read the reference for the task at hand:
references/validation-sync-and-async.md — sync validator returning localized String?, AutovalidateMode choice, and the debounced-async-in-a-Notifier pattern (why async must NOT live in the sync validator).
references/focus-and-keyboard.md — FocusNode lifecycle, traversal order, autofocus, FocusTraversalGroup, TextInputAction, onFieldSubmitted/onEditingComplete, keyboardType, autofillHints, TextInputFormatter, keyboard-avoidance.
Run scripts/check_forms.sh before a PR.
Non-negotiable rules
- Every
TextEditingController and FocusNode created in a State is disposed in dispose(). They hold native resources and listeners; a leak survives the widget and fires callbacks against a dead tree. If the value must outlive the widget, hold it in a Notifier instead — see state-management-riverpod.
- Validator messages are localized, never hardcoded. A
validator returns AppLocalizations.of(context).fieldRequired, not 'Required'. Error text is user-facing UI copy and is owned by i18n-rtl-l10n. The check_forms.sh grep fails on string literals returned from a validator.
- The sync
validator is pure and instant — no await, no network, no Future. FormFieldValidator<T> is String? Function(T?); it cannot be async and Flutter calls it synchronously during layout. Availability/uniqueness checks belong in a Notifier (rule 4).
- Async validation lives in a debounced Riverpod Notifier and surfaces through state. Debounce with a
dart:async Timer (kept deterministic in tests via fakeAsync, not by the clock), run the check, and expose AsyncValue/a sealed status the field reads via InputDecoration.errorText. Any timestamp the check records comes from ref.read(clockProvider).now(), never DateTime.now() — the Clock seam is owned by service-boundary-and-native. Never block a keystroke on I/O. See async-safety for cancel-on-dispose.
- Submit-enabled is DERIVED from validity, never stored as a separate
bool. A stored _isValid flag drifts out of sync with the fields. Compute it from FormState/Notifier state at build time. See flutter-performance (derive-don't-store).
- A keystroke rebuilds one field, not the whole form. Give each field its own controller/
FormField; do not lift raw text into a top-level setState/watch that rebuilds every sibling. Scope rebuilds with small widgets and ref.watch(....select(...)). See widget-composition and flutter-performance.
- Choose
AutovalidateMode deliberately. Default to AutovalidateMode.onUserInteraction: silent until the user touches a field, then live. Never always (screams before the user types). Validate-on-submit only for short forms where per-field feedback is noise.
- Keyboard type, capitalization, and autofill are declared per field.
keyboardType, textCapitalization, autofillHints, and TextInputFormatters are structural input contracts, not decoration. A missing autofillHints breaks OS autofill and password managers.
- Errors are announced, not just colored.
InputDecoration.labelText/errorText carry semantics that screen readers read on change; never signal an error with color alone. See accessibility-as-code.
Form skeleton
Form + a GlobalKey<FormState> is the coordination point. The key lets the submit handler call validate()/save() across all fields at once.
class TaskForm extends StatefulWidget {
const TaskForm({super.key, required this.onSubmit});
final void Function(String title) onSubmit;
@override
State<TaskForm> createState() => _TaskFormState();
}
class _TaskFormState extends State<TaskForm> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _titleFocus = FocusNode();
@override
void dispose() {
_titleController.dispose(); // rule 1: always dispose
_titleFocus.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState?.validate() ?? false) {
widget.onSubmit(_titleController.text.trim());
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, // rule 7
child: Column(
children: [
TextFormField(
controller: _titleController,
focusNode: _titleFocus,
autofocus: true,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.taskTitleLabel),
validator: (value) => // rule 2 + 3: localized, pure
(value == null || value.trim().isEmpty) ? l10n.fieldRequired : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
);
}
}
Sync validation
The validator is a total, synchronous function of the field value. Return null for valid, a localized message otherwise. Compose small checks; keep the closure short.
String? validateTitle(String? value, AppLocalizations l10n) {
final text = value?.trim() ?? '';
if (text.isEmpty) return l10n.fieldRequired;
if (text.length > 120) return l10n.fieldTooLong; // structural bound, not design
return null;
}
Async validation (out of the validator)
An availability check (is this account name taken?) is I/O. It runs in a Notifier, debounced against clockProvider, and the field reads the result through errorText. The sync validator stays pure and only guards the shape of the input. Full pattern in references/validation-sync-and-async.md and examples/async_field_notifier.dart.
// The field is driven by Notifier state, not by an async validator.
final status = ref.watch(nameAvailabilityNotifierProvider);
TextFormField(
controller: _nameController,
onChanged: ref.read(nameAvailabilityNotifierProvider.notifier).onNameChanged,
decoration: InputDecoration(
labelText: l10n.accountNameLabel,
errorText: switch (status) {
AsyncData(:final value) when value == NameCheck.taken => l10n.nameTaken,
AsyncError() => l10n.nameCheckFailed,
_ => null, // idle / loading / available: no error
},
),
);
Focus and keyboard flow
TextInputAction.next moves to the next field; .done submits. Advance focus in onFieldSubmitted with FocusScope.of(context).nextFocus() or by requesting a specific node. Group related fields with FocusTraversalGroup to control tab order. Details in references/focus-and-keyboard.md.
TextFormField(
focusNode: _titleFocus,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),
),
Submit button derived from validity
Do not store an _isFormValid bool. Derive enablement each build; disable while an async submit is in flight (from Notifier state).
final submitting = ref.watch(taskFormNotifierProvider).isLoading;
FilledButton(
onPressed: submitting ? null : _submit, // rule 5
child: Text(l10n.saveAction),
);
Keyboard avoidance
Wrap long forms so the focused field scrolls above the keyboard: a SingleChildScrollView inside the body lets Scaffold (with resizeToAvoidBottomInset: true, the default) push content up. For last-field submit, ensure the submit button is reachable — put it in the scroll view or a bottomNavigationBar.
Anti-patterns
validator: (v) async => await repo.isTaken(v) — a validator cannot be async; the Future is truthy so it always "passes." Move to a Notifier (rule 4).
- Returning
'Required' / 'Invalid email' from a validator — un-localized; breaks every non-English locale. Use AppLocalizations.
- Creating a
TextEditingController/FocusNode in build() — a fresh one every rebuild, losing text and cursor. Create in State, dispose in dispose().
bool _isValid toggled in onChanged to enable submit — drifts from real validity. Derive it.
autovalidateMode: AutovalidateMode.always — errors shout before the user types a character.
- One
TextEditingController listener that calls setState on the whole form — every keystroke rebuilds every field. Scope the rebuild.
debounce timing rolled by hand with DateTime.now() diffs — use a dart:async Timer (deterministic under fakeAsync); and any timestamp the check records comes from ref.read(clockProvider).now(), never DateTime.now().
Definition of done
- Every controller/
FocusNode disposed (or state lives in a Notifier); check_forms.sh clean.
- No string literal returned from any
validator; all messages via AppLocalizations.
- No
await/Future inside a sync validator; async checks in a debounced Notifier surfaced through errorText.
AutovalidateMode.onUserInteraction (or an intentional submit-only choice).
- Submit enablement derived, not stored; disabled during in-flight submit.
- Each field declares
keyboardType, textInputAction, and autofillHints where applicable; focus advances correctly.
- Errors announced via
InputDecoration semantics, never color-only.
Related skills
state-management-riverpod — the Notifier that owns async validation and submit state.
async-safety — cancel debounce timers/subscriptions on dispose; mounted guards after await.
i18n-rtl-l10n — localized validator messages and labels; the non-null AppLocalizations.of(context) getter.
accessibility-as-code — field labels, error announcement, never-color-alone, target sizes.
flutter-performance — scoped rebuilds and derive-don't-store.
widget-composition — small const field widgets over _buildField methods.
navigation-and-routing — PopScope for unsaved-changes confirmation when leaving a dirty form.
service-boundary-and-native — the Clock seam (clockProvider) any async check reads timestamps from.
References
1---2name: forms-and-input3description: Enforces Form + GlobalKey<FormState> with TextFormField whose sync validator returns a localized String? (never a hardcoded literal), AutovalidateMode.onUserInteraction, async availability checks moved OUT of the sync validator into a debounced Riverpod Notifier that surfaces errors through state, FocusNode/TextInputAction/onFieldSubmitted traversal, keyboardType/textCapitalization/autofillHints/TextInputFormatter, mandatory TextEditingController/FocusNode disposal, submit-enabled derived from validity (not stored), and scoped rebuilds so a keystroke never rebuilds the whole form. Use when building a Form, TextFormField, or FormField; wiring sync or async validation; managing FocusNode, focus traversal, autofocus, TextInputAction, onFieldSubmitted, or onEditingComplete; setting keyboardType, autofillHints, textCapitalization, or InputFormatter; disposing TextEditingController/FocusNode; enabling/disabling a submit button; or handling keyboard-avoidance on submit.4---56# Forms and input78Text input is where most disposal leaks, un-localized strings, and jank enter a Flutter app. A form is a small state machine: fields hold text, a `FormState` validates them, and a ViewModel owns anything that touches the network or the clock. Keep those three responsibilities separate.910Read the reference for the task at hand:1112- `references/validation-sync-and-async.md` — sync `validator` returning localized `String?`, `AutovalidateMode` choice, and the debounced-async-in-a-Notifier pattern (why async must NOT live in the sync validator).13- `references/focus-and-keyboard.md` — `FocusNode` lifecycle, traversal order, `autofocus`, `FocusTraversalGroup`, `TextInputAction`, `onFieldSubmitted`/`onEditingComplete`, `keyboardType`, `autofillHints`, `TextInputFormatter`, keyboard-avoidance.1415Run `scripts/check_forms.sh` before a PR.1617## Non-negotiable rules18191. **Every `TextEditingController` and `FocusNode` created in a `State` is disposed in `dispose()`.** They hold native resources and listeners; a leak survives the widget and fires callbacks against a dead tree. If the value must outlive the widget, hold it in a Notifier instead — see `state-management-riverpod`.202. **Validator messages are localized, never hardcoded.** A `validator` returns `AppLocalizations.of(context).fieldRequired`, not `'Required'`. Error text is user-facing UI copy and is owned by `i18n-rtl-l10n`. The `check_forms.sh` grep fails on string literals returned from a validator.213. **The sync `validator` is pure and instant — no `await`, no network, no `Future`.** `FormFieldValidator<T>` is `String? Function(T?)`; it cannot be async and Flutter calls it synchronously during layout. Availability/uniqueness checks belong in a Notifier (rule 4).224. **Async validation lives in a debounced Riverpod Notifier and surfaces through state.** Debounce with a `dart:async` `Timer` (kept deterministic in tests via `fakeAsync`, not by the clock), run the check, and expose `AsyncValue`/a sealed status the field reads via `InputDecoration.errorText`. Any *timestamp* the check records comes from `ref.read(clockProvider).now()`, never `DateTime.now()` — the Clock seam is owned by `service-boundary-and-native`. Never block a keystroke on I/O. See `async-safety` for cancel-on-dispose.235. **Submit-enabled is DERIVED from validity, never stored as a separate `bool`.** A stored `_isValid` flag drifts out of sync with the fields. Compute it from `FormState`/Notifier state at build time. See `flutter-performance` (derive-don't-store).246. **A keystroke rebuilds one field, not the whole form.** Give each field its own controller/`FormField`; do not lift raw text into a top-level `setState`/`watch` that rebuilds every sibling. Scope rebuilds with small widgets and `ref.watch(....select(...))`. See `widget-composition` and `flutter-performance`.257. **Choose `AutovalidateMode` deliberately.** Default to `AutovalidateMode.onUserInteraction`: silent until the user touches a field, then live. Never `always` (screams before the user types). Validate-on-submit only for short forms where per-field feedback is noise.268. **Keyboard type, capitalization, and autofill are declared per field.** `keyboardType`, `textCapitalization`, `autofillHints`, and `TextInputFormatter`s are structural input contracts, not decoration. A missing `autofillHints` breaks OS autofill and password managers.279. **Errors are announced, not just colored.** `InputDecoration.labelText`/`errorText` carry semantics that screen readers read on change; never signal an error with color alone. See `accessibility-as-code`.2829## Form skeleton3031`Form` + a `GlobalKey<FormState>` is the coordination point. The key lets the submit handler call `validate()`/`save()` across all fields at once.3233```dart34class TaskForm extends StatefulWidget {35 const TaskForm({super.key, required this.onSubmit});36 final void Function(String title) onSubmit;3738 @override39 State<TaskForm> createState() => _TaskFormState();40}4142class _TaskFormState extends State<TaskForm> {43 final _formKey = GlobalKey<FormState>();44 final _titleController = TextEditingController();45 final _titleFocus = FocusNode();4647 @override48 void dispose() {49 _titleController.dispose(); // rule 1: always dispose50 _titleFocus.dispose();51 super.dispose();52 }5354 void _submit() {55 if (_formKey.currentState?.validate() ?? false) {56 widget.onSubmit(_titleController.text.trim());57 }58 }5960 @override61 Widget build(BuildContext context) {62 final l10n = AppLocalizations.of(context);63 return Form(64 key: _formKey,65 autovalidateMode: AutovalidateMode.onUserInteraction, // rule 766 child: Column(67 children: [68 TextFormField(69 controller: _titleController,70 focusNode: _titleFocus,71 autofocus: true,72 textInputAction: TextInputAction.done,73 keyboardType: TextInputType.text,74 textCapitalization: TextCapitalization.sentences,75 decoration: InputDecoration(labelText: l10n.taskTitleLabel),76 validator: (value) => // rule 2 + 3: localized, pure77 (value == null || value.trim().isEmpty) ? l10n.fieldRequired : null,78 onFieldSubmitted: (_) => _submit(),79 ),80 ],81 ),82 );83 }84}85```8687## Sync validation8889The `validator` is a total, synchronous function of the field value. Return `null` for valid, a localized message otherwise. Compose small checks; keep the closure short.9091```dart92String? validateTitle(String? value, AppLocalizations l10n) {93 final text = value?.trim() ?? '';94 if (text.isEmpty) return l10n.fieldRequired;95 if (text.length > 120) return l10n.fieldTooLong; // structural bound, not design96 return null;97}98```99100## Async validation (out of the validator)101102An availability check (is this account name taken?) is I/O. It runs in a Notifier, debounced against `clockProvider`, and the field reads the result through `errorText`. The sync `validator` stays pure and only guards the shape of the input. Full pattern in `references/validation-sync-and-async.md` and `examples/async_field_notifier.dart`.103104```dart105// The field is driven by Notifier state, not by an async validator.106final status = ref.watch(nameAvailabilityNotifierProvider);107TextFormField(108 controller: _nameController,109 onChanged: ref.read(nameAvailabilityNotifierProvider.notifier).onNameChanged,110 decoration: InputDecoration(111 labelText: l10n.accountNameLabel,112 errorText: switch (status) {113 AsyncData(:final value) when value == NameCheck.taken => l10n.nameTaken,114 AsyncError() => l10n.nameCheckFailed,115 _ => null, // idle / loading / available: no error116 },117 ),118);119```120121## Focus and keyboard flow122123`TextInputAction.next` moves to the next field; `.done` submits. Advance focus in `onFieldSubmitted` with `FocusScope.of(context).nextFocus()` or by requesting a specific node. Group related fields with `FocusTraversalGroup` to control tab order. Details in `references/focus-and-keyboard.md`.124125```dart126TextFormField(127 focusNode: _titleFocus,128 textInputAction: TextInputAction.next,129 onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),130),131```132133## Submit button derived from validity134135Do not store an `_isFormValid` bool. Derive enablement each build; disable while an async submit is in flight (from Notifier state).136137```dart138final submitting = ref.watch(taskFormNotifierProvider).isLoading;139FilledButton(140 onPressed: submitting ? null : _submit, // rule 5141 child: Text(l10n.saveAction),142);143```144145## Keyboard avoidance146147Wrap long forms so the focused field scrolls above the keyboard: a `SingleChildScrollView` inside the body lets `Scaffold` (with `resizeToAvoidBottomInset: true`, the default) push content up. For last-field submit, ensure the submit button is reachable — put it in the scroll view or a `bottomNavigationBar`.148149## Anti-patterns150151- `validator: (v) async => await repo.isTaken(v)` — a validator cannot be async; the `Future` is truthy so it always "passes." Move to a Notifier (rule 4).152- Returning `'Required'` / `'Invalid email'` from a validator — un-localized; breaks every non-English locale. Use `AppLocalizations`.153- Creating a `TextEditingController`/`FocusNode` in `build()` — a fresh one every rebuild, losing text and cursor. Create in `State`, dispose in `dispose()`.154- `bool _isValid` toggled in `onChanged` to enable submit — drifts from real validity. Derive it.155- `autovalidateMode: AutovalidateMode.always` — errors shout before the user types a character.156- One `TextEditingController` listener that calls `setState` on the whole form — every keystroke rebuilds every field. Scope the rebuild.157- `debounce` timing rolled by hand with `DateTime.now()` diffs — use a `dart:async` `Timer` (deterministic under `fakeAsync`); and any timestamp the check records comes from `ref.read(clockProvider).now()`, never `DateTime.now()`.158159## Definition of done160161- Every controller/`FocusNode` disposed (or state lives in a Notifier); `check_forms.sh` clean.162- No string literal returned from any `validator`; all messages via `AppLocalizations`.163- No `await`/`Future` inside a sync `validator`; async checks in a debounced Notifier surfaced through `errorText`.164- `AutovalidateMode.onUserInteraction` (or an intentional submit-only choice).165- Submit enablement derived, not stored; disabled during in-flight submit.166- Each field declares `keyboardType`, `textInputAction`, and `autofillHints` where applicable; focus advances correctly.167- Errors announced via `InputDecoration` semantics, never color-only.168169## Related skills170171- `state-management-riverpod` — the Notifier that owns async validation and submit state.172- `async-safety` — cancel debounce timers/subscriptions on dispose; mounted guards after await.173- `i18n-rtl-l10n` — localized validator messages and labels; the non-null `AppLocalizations.of(context)` getter.174- `accessibility-as-code` — field labels, error announcement, never-color-alone, target sizes.175- `flutter-performance` — scoped rebuilds and derive-don't-store.176- `widget-composition` — small const field widgets over `_buildField` methods.177- `navigation-and-routing` — `PopScope` for unsaved-changes confirmation when leaving a dirty form.178- `service-boundary-and-native` — the Clock seam (`clockProvider`) any async check reads timestamps from.179180## References181182- Form: https://api.flutter.dev/flutter/widgets/Form-class.html183- TextFormField: https://api.flutter.dev/flutter/material/TextFormField-class.html184- FocusNode: https://api.flutter.dev/flutter/widgets/FocusNode-class.html185- TextInputAction: https://api.flutter.dev/flutter/services/TextInputAction.html186- Autofill: https://api.flutter.dev/flutter/services/AutofillHints-class.html187- Forms cookbook: https://docs.flutter.dev/cookbook/forms/validation