Forms, Validation, Focus & Controllers
Input Flows That Don't Frustrate Users — Senior-Level UX Patterns
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Flutter forms use TextEditingController for text access, FocusNode for keyboard control, Form + GlobalKey for coordinated validation, and TextFormField for fields with built-in validation. Input formatters constrain input on keystrokes. AutovalidateMode controls when errors appear. Together they create UX-safe, production-quality input flows.
Real-world relevance
In a school management platform, a student enrollment form has: name field (letters only, required), email field (format validation, async duplicate check), phone (digits only, length 10-15), date of birth (date picker). On submit, form.validate() triggers all validators, scroll animates to first error, and a loading spinner appears in the Save button while the API call completes.
Key points
- TextEditingController — The Full Picture — TextEditingController holds the text AND selection. Access text via .text, set it via .text = 'value'. Listen to changes via addListener(). MUST be disposed. Common mistake: creating a new controller in build() — creates a new controller every rebuild, losing the text. Create controllers in initState().
- Form and GlobalKey — Wrap fields in Form with a GlobalKey. Call _formKey.currentState!.validate() to trigger all validators. Call .save() to trigger all onSaved callbacks. Call .reset() to clear all fields. The Form widget itself has no visual appearance — it's a logical container for coordinated validation.
- FormField and TextFormField — TextFormField = TextField wrapped in FormField. FormField has: initialValue, validator, onSaved, autovalidateMode. validator returns null for valid input, or an error String. Don't use both controller and initialValue — they conflict. Use controller for programmatic control, initialValue for static defaults.
- AutovalidateMode — AutovalidateMode.disabled: validate only when form.validate() is called. AutovalidateMode.onUserInteraction: validate as user types after first interaction. AutovalidateMode.always: validate on every rebuild. Interview: What mode prevents showing errors before the user has typed anything? onUserInteraction.
- FocusNode — Keyboard and Focus Control — FocusNode tracks whether a field has keyboard focus. Use FocusScope.of(context).requestFocus(nextFocusNode) to move focus programmatically. textInputAction: TextInputAction.next on TextField + focusNode management creates a smooth 'tab' UX. MUST dispose FocusNodes.
- Input Formatters — Restrict Input — TextInputFormatter restricts what can be typed. FilteringTextInputFormatter.digitsOnly — numbers only. FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')) — letters only. LengthLimitingTextInputFormatter(10) — max 10 chars. Combine multiple formatters in a list. Applies on every keystroke — instant validation.
- Reactive Validation Patterns — For cross-field validation (e.g., confirm password must match password), use the Form widget's validate() combined with closure capturing other controllers. Or use a FormBloc/Riverpod provider that exposes per-field validation states. Interview: How do you validate that two password fields match?
- Obscure Text and Password Toggle — obscureText: true hides input. Toggle with setState on an _isObscure bool. For production: never log password field values, use secure text entry, disable autocorrect and enableSuggestions for password fields.
- Keyboard Type and Input Action — keyboardType: TextInputType.emailAddress shows '@' key. TextInputType.number shows numeric keyboard. TextInputType.multiline allows newlines. textInputAction: TextInputAction.done closes keyboard. TextInputAction.next moves to next field. TextInputAction.search triggers search. Match keyboard to expected input type.
- Form UX Best Practices — Show errors only after user interaction (onUserInteraction). Scroll to first error on submit. Auto-move focus to next field on completion. Debounce async validators (username availability). Disable submit button while validating or loading. Show loading spinner in submit button, not a separate overlay.
Code example
// Forms, Validation, Focus & Controllers
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class EnrollmentForm extends StatefulWidget {
const EnrollmentForm({super.key});
@override
State<EnrollmentForm> createState() => _EnrollmentFormState();
}
class _EnrollmentFormState extends State<EnrollmentForm> {
// GlobalKey: lets us call validate(), save(), reset() from outside Form
final _formKey = GlobalKey<FormState>();
// Controllers: created in initState, disposed in dispose
late final TextEditingController _nameController;
late final TextEditingController _emailController;
late final TextEditingController _passwordController;
late final TextEditingController _confirmPasswordController;
late final TextEditingController _phoneController;
// FocusNodes: for keyboard navigation
late final FocusNode _emailFocus;
late final FocusNode _passwordFocus;
late final FocusNode _confirmFocus;
late final FocusNode _phoneFocus;
bool _isLoading = false;
bool _isPasswordVisible = false;
Timer? _emailDebounce;
String? _emailAsyncError;
@override
void initState() {
super.initState();
_nameController = TextEditingController();
_emailController = TextEditingController();
_passwordController = TextEditingController();
_confirmPasswordController = TextEditingController();
_phoneController = TextEditingController();
_emailFocus = FocusNode();
_passwordFocus = FocusNode();
_confirmFocus = FocusNode();
_phoneFocus = FocusNode();
}
@override
void dispose() {
// Dispose ALL controllers and FocusNodes
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_phoneController.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
_confirmFocus.dispose();
_phoneFocus.dispose();
_emailDebounce?.cancel();
super.dispose();
}
// Async validation with debounce — check email availability
void _onEmailChanged(String email) {
_emailDebounce?.cancel();
_emailDebounce = Timer(const Duration(milliseconds: 500), () async {
// Simulate API call
final isTaken = await _checkEmailTaken(email);
if (mounted) {
setState(() {
_emailAsyncError = isTaken ? 'Email already registered' : null;
});
}
});
}
Future<bool> _checkEmailTaken(String email) async {
await Future.delayed(const Duration(milliseconds: 300));
return email == 'taken@example.com'; // Simulate
}
// Cross-field validation: confirm password must match
String? _validateConfirmPassword(String? value) {
if (value == null || value.isEmpty) return 'Please confirm your password';
if (value != _passwordController.text) return 'Passwords do not match';
return null; // Valid
}
Future<void> _handleSubmit() async {
// Dismiss keyboard
FocusScope.of(context).unfocus();
// Validate all fields synchronously
if (!_formKey.currentState!.validate()) {
// TODO: Scroll to first error
return;
}
// Save all fields (triggers onSaved callbacks)
_formKey.currentState!.save();
setState(() => _isLoading = true);
try {
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enrolled successfully!')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
// Validate after user's first interaction — no errors on fresh form
autovalidateMode: AutovalidateMode.onUserInteraction,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// --- NAME FIELD: letters only ---
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Full Name'),
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next, // Shows 'next' on keyboard
inputFormatters: [
// Only allow letters and spaces
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z ]')),
],
validator: (value) {
if (value == null || value.trim().isEmpty) return 'Name is required';
if (value.trim().length < 2) return 'Name too short';
return null;
},
onFieldSubmitted: (_) {
// Move focus to email on keyboard 'next' tap
FocusScope.of(context).requestFocus(_emailFocus);
},
),
const SizedBox(height: 16),
// --- EMAIL FIELD: async validation ---
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
decoration: InputDecoration(
labelText: 'Email',
// Show async error below standard validator error
errorText: _emailAsyncError,
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
onChanged: _onEmailChanged,
validator: (value) {
if (value == null || value.isEmpty) return 'Email is required';
if (!RegExp(r'^[\w.-]+@[\w-]+\.[\w.]+$').hasMatch(value)) {
return 'Invalid email format';
}
return null;
},
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(_passwordFocus);
},
),
const SizedBox(height: 16),
// --- PASSWORD FIELD: obscure with toggle ---
TextFormField(
controller: _passwordController,
focusNode: _passwordFocus,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(_isPasswordVisible
? Icons.visibility_off
: Icons.visibility),
onPressed: () {
setState(() => _isPasswordVisible = !_isPasswordVisible);
},
),
),
obscureText: !_isPasswordVisible,
textInputAction: TextInputAction.next,
enableSuggestions: false,
autocorrect: false,
validator: (value) {
if (value == null || value.isEmpty) return 'Password is required';
if (value.length < 8) return 'At least 8 characters';
if (!RegExp(r'[A-Z]').hasMatch(value)) return 'Include uppercase letter';
if (!RegExp(r'[0-9]').hasMatch(value)) return 'Include a number';
return null;
},
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(_confirmFocus);
},
),
const SizedBox(height: 16),
// --- CONFIRM PASSWORD: cross-field validation ---
TextFormField(
controller: _confirmPasswordController,
focusNode: _confirmFocus,
decoration: const InputDecoration(labelText: 'Confirm Password'),
obscureText: true,
textInputAction: TextInputAction.next,
enableSuggestions: false,
validator: _validateConfirmPassword, // Captures passwordController
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(_phoneFocus);
},
),
const SizedBox(height: 16),
// --- PHONE FIELD: digits only, length constraint ---
TextFormField(
controller: _phoneController,
focusNode: _phoneFocus,
decoration: const InputDecoration(labelText: 'Phone'),
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(15),
],
validator: (value) {
if (value == null || value.isEmpty) return 'Phone is required';
if (value.length < 10) return 'Enter at least 10 digits';
return null;
},
onFieldSubmitted: (_) => _handleSubmit(),
),
const SizedBox(height: 24),
// --- SUBMIT BUTTON: disabled while loading ---
ElevatedButton(
onPressed: _isLoading ? null : _handleSubmit,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Enroll'),
),
],
),
);
}
}Line-by-line walkthrough
- 1. GlobalKey: allows validate(), save(), reset() calls from submit button
- 2. Controllers and FocusNodes created in initState — NOT in build()
- 3. All controllers and FocusNodes disposed in dispose() — prevents memory leaks
- 4. _emailDebounce: waits 500ms after typing stops before making async API call
- 5. _validateConfirmPassword: closure captures _passwordController — cross-field validation
- 6. autovalidateMode: onUserInteraction — shows errors only after user types
- 7. FilteringTextInputFormatter.allow(RegExp): letters and spaces only on name field
- 8. focusNode on email field + onFieldSubmitted moves focus to password field
- 9. obscureText toggled by _isPasswordVisible state + visibility toggle button
- 10. enableSuggestions: false on password — prevents autocomplete on sensitive fields
- 11. LengthLimitingTextInputFormatter(15) + digitsOnly on phone
- 12. Submit button: null onPressed when loading disables it; shows spinner inside button
Spot the bug
class SignupForm extends StatefulWidget {
const SignupForm({super.key});
@override
State<SignupForm> createState() => _SignupFormState();
}
class _SignupFormState extends State<SignupForm> {
@override
Widget build(BuildContext context) {
final controller = TextEditingController();
return TextField(controller: controller);
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Forms in Flutter (Flutter Official)
- TextEditingController (Flutter API)
- FocusNode (Flutter API)
- TextFormField (Flutter API)