Ship forms with Form / FormField on Flutter 3.47+. Do not add GetX, flutter_form_builder, or Expo/RN form patterns. If this repo already has a Form wrapper, shared validators, ThemeData.inputDecorationTheme, or an AutofillGroup around auth, extend those. If widgets already read a house ThemeExtension or *Pressable, style fields from that system — do not drop a stock Material decoration theme onto a branded surface.
Scroll the form with the project's existing ListView / CustomScrollView. A bare Column under the keyboard will overflow.
References
references/
recipes.md Login/signup autofill, server errors, custom FormField, lazy lists
Hard rules
- Own
final GlobalKey<FormState> _formKeyon aState. Never allocate that key, aFocusNode, or aTextEditingControllerinsidebuild. Submit path:if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); … }. After a failed submit,validateGranularly()thenFocusNode.requestFocus()on the first invalid field in visual order (Setis unordered). - Inside a
Form, every value that must validate or save is aTextFormField,DropdownMenuFormField, or customFormField<T>— never a bareTextField. validatorisFormFieldValidator<T>?and returnsString?(null= valid). Keep it cheap and synchronous. Put API/server failures inforceErrorText.- Default
autovalidateModeisAutovalidateMode.disabled. After first submit preferonUserInteractionoronUnfocus. Do not usealwayson required-empty fields. There is noAutovalidateMode.onFocusChange. Also available:onUserInteractionIfError. - Style via
ThemeData.inputDecorationTheme(InputDecorationThemeData). Per-fieldInputDecorationsets onlylabelText/hintText/helperText/prefixIcon. - Set
keyboardTypeandtextInputActionon every field (nextuntil the last, thendone/go/send+onFieldSubmitted). - Set
autofillHints: const [AutofillHints.…]. Login:username+password. Signup:newUsername+newPassword. Alsoemail,oneTimeCode. Wrap the group inAutofillGroup. On successful submit callTextInput.finishAutofillContext(shouldSave: true)once — after the last step of a multi-step form. - Create controllers and
FocusNodes once onState;dispose()them. In a lazyListView/CustomScrollView, the controller must be owned by a stateful ancestor. - Reserve helper/error space (
helperText: ' 'or a fixed-height parent) so showingerrorTextdoes not reflow the form. - New dropdowns in a Form:
DropdownMenuFormField. If you still touchDropdownButtonFormField, passinitialValue, not deprecatedvalue. autofocusthe first field. Wrap the form inFocusTraversalGroup. UseOrderedTraversalPolicy+NumericFocusOrderonly when visual order ≠ reading order.- Interactive fields get
const ValueKey<String>('kebab-name').
Own the Form
class _SignInState extends State<SignInPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
late final FocusNode _emailFocus = FocusNode();
late final TextEditingController _email = TextEditingController();
AutovalidateMode _auto = AutovalidateMode.disabled;
@override
void dispose() {
_emailFocus.dispose();
_email.dispose();
super.dispose();
}
void _submit() {
final Set<FormFieldState<Object?>> invalid =
_formKey.currentState!.validateGranularly();
if (invalid.isNotEmpty) {
setState(() => _auto = AutovalidateMode.onUserInteraction);
_emailFocus.requestFocus(); // first invalid field in visual order
return;
}
_formKey.currentState!.save();
TextInput.finishAutofillContext(shouldSave: true);
}
}
Nested widgets can call Form.of(context) (asserts if missing) or Form.maybeOf instead of threading the key. Validate a form.
validate() runs every descendant validator, rebuilds to show errors, and returns false if any fail. save() only invokes onSaved — it does not validate. Call save() after a successful validate() if you use onSaved. reset() restores each field to FormField.initialValue. validateGranularly() returns the Set of invalid FormFieldStates. FormState.validate.
Fields
TextFormField is a FormField<String> wrapping TextField. A Form ancestor is optional; without one, pass GlobalKey<FormFieldState> and call validate / save / reset on that state. Use TextField only when the editor must not participate in FormState. If you pass a TextEditingController, its .text is the field's initialValue. FormField.initialValue applies on first build and again on reset(). TextFormField.
Dropdown that must validate / save / reset:
DropdownMenuFormField<String>(
key: const ValueKey<String>('country-field'),
dropdownMenuEntries: const <DropdownMenuEntry<String>>[
DropdownMenuEntry<String>(value: 'us', label: 'United States'),
],
validator: (String? v) => v == null ? 'Pick a country' : null,
onSaved: (String? v) { /* store */ },
)
Seed the selection with initialSelection (maps to FormField.initialValue). Raw DropdownMenu is not a FormField. DropdownMenuFormField.
Custom checkbox groups, sliders, chip pickers: a FormField<T> whose builder reads FormFieldState and calls state.didChange(newValue). FormField.
Validation
TextFormField(
validator: (String? v) {
if (v == null || v.isEmpty) return 'Required';
return null;
},
forceErrorText: serverEmailError, // null resumes validator
autovalidateMode: AutovalidateMode.onUserInteractionIfError,
decoration: const InputDecoration(
labelText: 'Email',
helperText: ' ',
),
)
forceErrorText writes FormFieldState.errorText without running validator. While it is non-null, validator is not called and it overrides InputDecoration.errorText. Clear it to resume local validation. forceErrorText.
Use autovalidateMode, never the removed autovalidate bool. AutovalidateMode:
| Value | Runs |
|---|---|
disabled |
Only validate / validateGranularly (Form/FormField default) |
always |
First build and every change (empty required fields scream on paint) |
onUserInteraction |
After the field's content changes |
onUnfocus |
After the field loses focus |
onUserInteractionIfError |
On each change only once the field already has an error |
When errorText is non-null the helper is hidden and the field can grow. Reserve space with helperText: ' ' or a fixed-height parent.
Keyboard, action, autofill
Match iOS hints to keyboards: AutofillHints.email + TextInputType.emailAddress, AutofillHints.name + TextInputType.name. TextInputType.
TextInputAction.next moves focus to the next focusable in the same FocusScope. Intermediate fields: next. Last field: done (or go / search / send) and handle submit in onFieldSubmitted. Do not use previous or none on iOS. TextInputAction.
Pass autofillHints: const [AutofillHints.email] — not ad-hoc strings unless you branch on defaultTargetPlatform. Do not pass null (opts the field out on Android/web). iOS and web use only the first hint; Android uses the whole list.
Wrap one login / signup / checkout group in AutofillGroup. Clients must be built together — a never-laid-out ListView child is not registered. Call TextInput.finishAutofillContext(shouldSave: true) on successful submit, once per screen, after the last step. AutofillGroup.
Focus
Create the FocusNode in initState or as a late field initialized once. autofocus: true on the first field. After failed validation, myFocusNode.requestFocus() — do not call FocusScope.of(context).requestFocus(focusNode). Focus cookbook.
Default tab/reading order is ReadingOrderTraversalPolicy. Wrap the form in FocusTraversalGroup. When visual order ≠ traversal order:
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: emailField,
),
)
Mixing incomparable FocusOrder types in one group asserts. Focus.
Theme
ThemeData(
inputDecorationTheme: const InputDecorationThemeData(
filled: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
floatingLabelBehavior: FloatingLabelBehavior.auto,
errorMaxLines: 2,
),
)
InputDecoration.applyDefaults fills only null decoration fields from this theme.
Do not
- Allocate
GlobalKey<FormState>,FocusNode, orTextEditingControllerinsidebuild. - Put a bare
TextFieldunderFormand expectFormState.validate()to see it. - Use deprecated
autovalidate: true, inventAutovalidateMode.onFocusChange, or setalwaysso empty required fields error on first paint. - Drive errors only via
InputDecoration.errorTextwhile also using avalidator. - Omit
autofillHints, set them tonull, or useAutofillHints.passwordon a create-account field (newPassword). - Pass
DropdownButtonFormField.valueinstead ofinitialValue, or use rawDropdownMenuwhen the form mustvalidate()the selection. - Drop
TextFormFields into a lazyListViewwithout a long-lived ancestor-ownedcontroller. - Copy-paste full
InputDecorationborders/colors/padding on every field. - Skip
dispose()on controllers/focus nodes, or skipTextInput.finishAutofillContextafter a successful credential submit. - Let error text appear with no reserved helper line.
Verify
grepforTextField(under aForm,autovalidate:,AutovalidateMode.always,autofillHints: null,DropdownButtonFormField+value:, and anyGlobalKey/FocusNode/TextEditingController(created insidebuild.- Every validating field is a
FormField. Every login/signup cluster sits in oneAutofillGroupwhose children are built together. dart analyze/flutter analyzeclean. Exercise empty, invalid, server-error, and keyboard-open states.