Better Forms in Flutter: Validation, Focus and Autofill Users Won't Hate
How I build better forms in Flutter — validation timing, keyboard and focus handling, autofill, server errors shown inline, and no double submits.

Forms are where users decide whether your app respects their time. A sign-up screen that yells “Invalid email” before you’ve finished typing, a phone field that opens the full keyboard, a submit button that happily sends the same order twice — none of it is hard to fix, and all of it costs you users.
I’ve built a lot of forms: booking flows, CRM screens in Dent Shop Manager, checkout and onboarding in client apps. The same handful of details separates the ones people breeze through from the ones they abandon.
Here’s the checklist I work through, with the Flutter code for each.
Start with Form and TextFormField
Flutter’s Form widget groups fields so you can validate them together. Each TextFormField gets a validator that returns an error string, or null when the value is fine:
final _formKey = GlobalKey<FormState>();
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _email,
decoration: const InputDecoration(labelText: 'Email'),
validator: (value) {
final v = value?.trim() ?? '';
if (v.isEmpty) return 'Enter your email';
if (!v.contains('@')) return 'That doesn\'t look like an email';
return null;
},
),
// more fields...
],
),
)
Then _formKey.currentState!.validate() on submit runs every validator and shows the errors.
Keep validators kind and specific. “Enter your email” is better than “Required”. “Password needs at least 8 characters” is better than “Invalid password”. And don’t over-validate emails with a giant regex — the server is the final judge, and rejecting a valid address is worse than accepting a typo.
Validate at the right moment
The default is to validate only when you call validate(). The other extreme, AutovalidateMode.always, shows errors on fields the user hasn’t touched yet, which feels like being scolded before you’ve started.
The setting I use almost everywhere is onUserInteraction:
TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
// ...
)
A field stays quiet until the user has changed it, then re-validates as they type, so the error disappears the moment they fix it. Combined with a full validate() on submit, this covers both the “I’m typing” and the “I skipped this field” cases.
Get the keyboard right
Two properties do most of the work:
keyboardTypechooses the right keyboard:TextInputType.emailAddress,TextInputType.phone,TextInputType.number,TextInputType.url. A phone field showing a QWERTY keyboard is an instant annoyance.textInputActioncontrols the action button. UseTextInputAction.nexton every field except the last, andTextInputAction.done(orsend/go) on the last one.
With TextInputAction.next, Flutter moves focus to the next field in traversal order by default. On the last field, hook the action to submit:
TextFormField(
controller: _password,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
)
Now a user can fill the whole form without touching anything but the keyboard.
When you need FocusNode
Default traversal follows widget order, which is usually right. When it isn’t — a two-column layout on tablets, or skipping an optional field — use FocusNodes and request focus explicitly:
final _cityFocus = FocusNode();
TextFormField(
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _cityFocus.requestFocus(),
),
TextFormField(focusNode: _cityFocus),
Remember to dispose focus nodes in dispose(), just like controllers. For whole-screen ordering, FocusTraversalGroup with an OrderedTraversalPolicy is another option.
Let autofill do the typing
Password managers and the OS can fill names, emails, addresses and one-time codes — but only if you tell them what each field is. Add autofillHints and wrap related fields in an AutofillGroup:
AutofillGroup(
child: Column(
children: [
TextFormField(
controller: _email,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
textInputAction: TextInputAction.next,
),
TextFormField(
controller: _password,
obscureText: _obscure,
autofillHints: const [AutofillHints.password],
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
tooltip: _obscure ? 'Show password' : 'Hide password',
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
),
],
),
)
Use AutofillHints.newPassword on sign-up so the OS offers to generate and save a strong password, and AutofillHints.oneTimeCode on OTP fields. After a successful login, calling TextInput.finishAutofillContext() nudges the platform to offer saving the credentials.
Notice the show/hide toggle on the password field, with a tooltip. Typing a long password blind on a phone keyboard is miserable; let people check what they typed.
Shape input with formatters
inputFormatters stop bad input from being typed at all, which beats complaining about it afterwards:
TextFormField(
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
)
For things like card numbers or phone masks you can write a custom TextInputFormatter, but be careful: formatters that insert spaces or dashes make cursor positioning tricky. Test editing in the middle of the value, not just typing at the end.
Show server errors where they belong
Local validation catches typos. The server catches everything else: email already registered, coupon expired, address not deliverable. Too many apps show these as a generic snackbar and leave the user hunting for the problem.
I keep a map of server errors keyed by field and let each validator check it first:
Map<String, String> _serverErrors = {};
TextFormField(
controller: _email,
validator: (v) => _serverErrors['email'] ?? _validateEmail(v),
onChanged: (_) {
if (_serverErrors.remove('email') != null) setState(() {});
},
)
After a failed request, fill the map from the API response and call validate() again. The error appears under the right field, and clears as soon as the user edits it. This is also a good argument for your API returning structured, per-field errors — something I push for when I’m building the .NET API behind a Flutter app.
Never lose what the user typed
Nothing makes people quit faster than retyping a long form.
- Keep values in controllers owned by a
Stateor your state management layer, not rebuilt on every build. If an error rebuilds the screen, the input survives. - Never clear the form on a failed submit. Clear the password at most, if security requires it.
- Warn before discarding. If the user has changed something and hits back, ask first.
PopScopehandles this:
PopScope(
canPop: !_isDirty,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final leave = await _confirmDiscard(context);
if (leave && context.mounted) Navigator.of(context).pop();
},
child: form,
)
For long forms, consider saving a draft locally so it survives the app being killed in the background.
Prevent double submits
On a slow network, users tap the button again. Then you get two orders, two bookings or two charges. Guard against it in the UI and make the server operation idempotent:
bool _submitting = false;
Future<void> _submit() async {
if (_submitting) return;
if (!_formKey.currentState!.validate()) return;
setState(() => _submitting = true);
try {
await _api.createBooking(_buildRequest());
if (mounted) Navigator.of(context).pop();
} on ApiValidationException catch (e) {
setState(() => _serverErrors = e.fieldErrors);
_formKey.currentState!.validate();
} finally {
if (mounted) setState(() => _submitting = false);
}
}
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox.square(
dimension: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Book now'),
)
The early return protects against a fast double tap before the rebuild happens, and the null onPressed disables the button visually. Keep the button the same size while loading so the layout doesn’t jump.
Scroll to the first error
On a long form, the error might be off screen. The user taps submit, nothing visibly happens, and they assume the app is broken.
Give each field a GlobalKey and, after a failed validation, scroll the first invalid one into view:
void _scrollToFirstError() {
for (final key in _fieldKeys) {
final state = key.currentState;
if (state != null && state.hasError) {
Scrollable.ensureVisible(
key.currentContext!,
duration: const Duration(milliseconds: 300),
alignment: 0.1,
);
break;
}
}
}
Here _fieldKeys is a list of GlobalKey<FormFieldState<String>> in visual order, each passed as the key of a TextFormField. Requesting focus on that field afterwards is a nice touch, too.
Make it accessible
- Use
labelText, not justhintText. Hints disappear when typing starts and screen readers handle labels better. - Errors are announced by screen readers when they appear in the decoration, which is another reason to show server errors inline rather than in a toast.
- Give icon buttons a
tooltip— it doubles as the semantic label. - Check large text sizes. Labels and errors should wrap, not clip.
There’s a broader walkthrough in accessibility in Flutter apps.
Takeaways
- Use
AutovalidateMode.onUserInteractionplus a fullvalidate()on submit. - Set
keyboardType,textInputActionandautofillHintson every field — they’re free wins. - Show server errors inline under the field they belong to, and clear them on edit.
- Never throw away user input on errors or accidental back navigation.
- Disable submit while a request is in flight, and make the server idempotent anyway.
- Scroll to the first error so a failed submit is never silent.
If your app’s forms are leaking users and you’d like someone to go through them field by field, get in touch.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).