About flutter-building-forms
A form-building skill for Flutter developers that sets patterns for Form fields, validation, focus, keyboards, and autofill. It emphasizes extending existing project conventions and includes guidance for submission, error handling, and verification.
Key features
- 01Choose Form-aware field widgets
- 02Handle validation and server errors
- 03Configure keyboard, focus, and autofill
- 04Follow themes and reserve error space
- 05Run form-specific verification checks
Use cases
- 01Build login or signup forms
- 02Validate dropdown selections
- 03Display server-side field errors
- 04Review keyboard and focus behavior
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ thesimpleapp/ flutter-app-skills --skill flutter-building-formsAfter installing, type/flutter-building-formsin Claude Code to confirm it loaded.
Bundled filesreferences/recipes.md
Details
From the project READMEFlutter building forms
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.