How do you handle form validation?
3 min read
Widgets & UI
Theory — the pieces
| Piece | What it does |
|---|---|
Form | Container that coordinates a group of form fields |
GlobalKey<FormState> | Handle to call .validate(), .save(), .reset() from outside |
TextFormField | TextField + validator + onSaved hook |
validator: (v) => v.isEmpty ? 'Required' : null | Return error message, or null if valid |
autovalidateMode | When validators run — disabled, always, onUserInteraction, onUnfocus (3.19+) |
TextEditingController | Read/write the field's current value programmatically |
Code in action
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _email = TextEditingController();
final _pwd = TextEditingController();
@override
void dispose() {
_email.dispose();
_pwd.dispose();
super.dispose();
}
String? _emailRule(String? v) {
if (v == null || v.isEmpty) return 'Email is required';
if (!v.contains('@')) return 'Enter a valid email';
return null; // ✅ valid
}
String? _pwdRule(String? v) {
if (v == null || v.isEmpty) return 'Password is required';
if (v.length < 8) return 'At least 8 characters';
return null;
}
void _submit() {
if (_formKey.currentState!.validate()) {
// all good — submit
api.login(_email.text, _pwd.text);
}
}
@override
Widget build(BuildContext context) => Form(
key: _formKey,
child: Column(children: [
TextFormField(
controller: _email,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: _emailRule,
),
const SizedBox(height: 16),
TextFormField(
controller: _pwd,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: _pwdRule,
),
const SizedBox(height: 24),
ElevatedButton(onPressed: _submit, child: const Text('Log in')),
]),
);
}
When to use what
| Situation | Approach |
|---|---|
| Simple form (login, signup, settings) | Form + TextFormField + validator |
| Async validation (check email exists) | Disable button while loading; validate manually before submit |
| Complex domain validation across fields | Validate at submit time with custom logic, not per-field |
| Reactive validation (validate on every keystroke) | autovalidateMode: AutovalidateMode.always |
| Large form / nested sections | Split into multiple Forms, validate each, or use a form package (reactive_forms, flutter_form_builder) |
Common mistakes to avoid
// ❌ Validators that read controllers instead of the passed value
validator: (_) => _email.text.isEmpty ? 'required' : null;
// ✅ Use the parameter — it stays in sync with the field
validator: (v) => v!.isEmpty ? 'required' : null;
// ❌ Forgetting to dispose controllers → memory leak
// ✅ override dispose() and call ctrl.dispose() for each
// ❌ Validating only on submit and ignoring inline UX
// ✅ autovalidateMode: AutovalidateMode.onUserInteraction is usually right
// ❌ Trying to do async work inside `validator`
validator: (v) async { ... } // ❌ must return String?, not Future
// ✅ Run the async check on submit and call setState with the error message
// ❌ Mixing controller and onChanged for the same field
// Pick one — controller for read/programmatic edits, onChanged for value-only state
// ❌ Showing errors before the user touches anything
// ✅ AutovalidateMode.onUserInteraction (Flutter > 2.10) does the right thing
Interview follow-ups
-
How do you handle async validation (e.g., "is this email taken")? Validators must be synchronous (return
String?, not a Future). Run the async check separately — typically on submit, while disabling the button and showing a spinner. If it fails, surface the error viasetState(or by reusing a field-level error variable that yourvalidatorreturns). -
What's the difference between
onChanged,onSaved, andcontroller?onChangedfires every keystroke.onSavedruns only when you call_formKey.currentState!.save()— useful for collecting form values at submit time. ATextEditingControllerlets you read/write the value programmatically and survives rebuilds. -
When would you use multiple Forms on one screen? For independent sections (e.g., "address" and "payment") where you want them validated separately, or for multi-step wizards. Each has its own
GlobalKey<FormState>and validates in isolation. -
What does
autovalidateMode: AutovalidateMode.onUserInteractionactually do? Errors don't show until the field has been touched or the form has been submitted once. This avoids the bad UX of yelling "Email required" the moment the screen opens.
How helpful was this content?
Please sign in to rate this article.