Intermediate · Lesson 3 · 11 min read
Forms, Validation and Input UX
Build forms with Form and TextFormField, write reusable validators, and get keyboard, focus and submission behaviour right.
Updated July 27, 2026
What you will learn
- Use Form, GlobalKey and TextFormField together
- Write composable, reusable validators
- Manage focus order and keyboard actions
- Handle async submission and server-side validation errors
Flutter's form system is small: a Form widget that coordinates, a GlobalKey<FormState> that gives you a handle on it, and TextFormFields that register themselves with the nearest Form.
A complete sign-up form
class SignUpForm extends StatefulWidget {
const SignUpForm({super.key});
@override
State<SignUpForm> createState() => _SignUpFormState();
}
class _SignUpFormState extends State<SignUpForm> {
final _formKey = GlobalKey<FormState>();
final _email = TextEditingController();
final _password = TextEditingController();
final _passwordFocus = FocusNode();
bool _submitting = false;
bool _obscure = true;
String? _serverError;
@override
void dispose() {
_email.dispose();
_password.dispose();
_passwordFocus.dispose();
super.dispose();
}
Future<void> _submit() async {
// Runs every field's validator and shows the messages
if (!_formKey.currentState!.validate()) return;
setState(() {
_submitting = true;
_serverError = null;
});
try {
await auth.signUp(email: _email.text.trim(), password: _password.text);
if (!mounted) return;
Navigator.of(context).pushReplacementNamed('/home');
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _serverError = e.message);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
// Only show errors after the first submit attempt
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _email,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.mail_outline),
),
validator: validateEmail,
onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: _password,
focusNode: _passwordFocus,
obscureText: _obscure,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
validator: validatePassword,
onFieldSubmitted: (_) => _submit(),
),
if (_serverError != null) ...[
const SizedBox(height: 12),
Text(
_serverError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create account'),
),
],
),
);
}
}Reusable validators
A validator is just a function from String? to String? — return null when valid, a message when not. Because they are plain functions, you can compose and unit-test them without a widget tree.
typedef Validator = String? Function(String?);
String? required_(String? value) =>
(value == null || value.trim().isEmpty) ? 'This field is required' : null;
String? validateEmail(String? value) {
if (value == null || value.trim().isEmpty) return 'Email is required';
final pattern = RegExp(r'^[\w.+-]+@[\w-]+\.[\w.-]+$');
return pattern.hasMatch(value.trim()) ? null : 'Enter a valid email';
}
String? validatePassword(String? value) {
if (value == null || value.isEmpty) return 'Password is required';
if (value.length < 8) return 'Use at least 8 characters';
if (!value.contains(RegExp(r'[0-9]'))) return 'Include at least one number';
return null;
}
/// Runs validators in order and returns the first failure.
Validator compose(List<Validator> validators) {
return (value) {
for (final validate in validators) {
final error = validate(value);
if (error != null) return error;
}
return null;
};
}
// Usage
validator: compose([required_, validateEmail]),Keyboard and focus details
textInputAction: TextInputAction.nextturns the keyboard's return key into a Next button;.donesubmits.autofillHintslets password managers and the OS fill fields — a genuine conversion win, and free.keyboardTypepicks the right keyboard:emailAddress,phone,number,multiline.- Wrap the form in a
SingleChildScrollViewso the keyboard never covers the submit button. TextInputFormatters restrict input at the source — digits only, length caps, currency masks.
TextFormField(
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
decoration: const InputDecoration(labelText: 'Verification code'),
)Saving values with onSaved
If you prefer not to keep a controller per field, onSaved plus _formKey.currentState!.save() collects values in one pass.
String _name = '';
TextFormField(
decoration: const InputDecoration(labelText: 'Full name'),
validator: required_,
onSaved: (value) => _name = value?.trim() ?? '',
)
void _submit() {
final form = _formKey.currentState!;
if (!form.validate()) return;
form.save(); // triggers every onSaved
submitToServer(_name);
}Use controllers when you need to read or change the text as the user types; use onSaved for simple forms you only read at submission time.
Preventing accidental data loss
PopScope(
canPop: !_hasUnsavedChanges,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final leave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard changes?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Keep editing'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Discard'),
),
],
),
);
if (leave == true && context.mounted) Navigator.pop(context);
},
child: form,
)Key takeaways
Form+GlobalKey<FormState>coordinates validation; validators are plain testable functions.AutovalidateMode.onUserInteractionavoids yelling at users before they have typed anything.- Set
textInputAction,keyboardTypeandautofillHints— small details, large UX difference. - Always disable the submit button while a submission is in flight.
Practice
Checkout form
Build a checkout form with name, email, address, card number (digits only, formatted in groups of four) and expiry (MM/YY). Validate each field, wire focus so the return key advances, block navigation away with unsaved changes, and unit-test every validator.
Show hints
- A custom
TextInputFormattercan insert spaces as the user types. - Validators are pure functions — test them with
flutter test, no widgets required.