Flutter Lesson 29 of 83 4 min read
Forms and Validation in Flutter
Build validated forms in Flutter with Form, TextFormField and GlobalKey: validators, error messages, saving values and submitting.
On this page
For a single field, TextField is enough. For several fields that must all be valid before submitting, use a Form. It validates, saves and resets all its fields together.
A complete form #
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: SignUpPage()));
class SignUpPage extends StatefulWidget {
const SignUpPage({super.key});
@override
State<SignUpPage> createState() => _SignUpPageState();
}
class _SignUpPageState extends State<SignUpPage> {
final _formKey = GlobalKey<FormState>();
final _email = TextEditingController();
final _password = TextEditingController();
@override
void dispose() {
_email.dispose();
_password.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return; // shows the errors
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Account created for ${_email.text}')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign up')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24),
children: [
TextFormField(
controller: _email,
decoration: const InputDecoration(labelText: 'Email', border: OutlineInputBorder()),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) return 'Enter your email';
if (!value.contains('@') || !value.contains('.')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _password,
decoration: const InputDecoration(labelText: 'Password', border: OutlineInputBorder()),
obscureText: true,
validator: (value) {
if (value == null || value.length < 8) return 'Use at least 8 characters';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
decoration: const InputDecoration(labelText: 'Confirm password', border: OutlineInputBorder()),
obscureText: true,
validator: (value) => value != _password.text ? 'Passwords do not match' : null,
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
FilledButton(onPressed: _submit, child: const Text('Create account')),
],
),
),
);
}
}
How it works #
| Piece | Role |
|---|---|
GlobalKey<FormState> | A handle for calling methods on the form |
Form(key: ...) | Groups the fields |
TextFormField | A TextField that takes part in the form |
validator | Returns an error message, or null when the value is fine |
_formKey.currentState!.validate() | Runs every validator, shows the messages, and returns true if all passed |
Reusable validators #
abstract final class Validators {
static String? required(String? v) =>
(v == null || v.trim().isEmpty) ? 'This field is required' : null;
static String? email(String? v) {
if (v == null || v.trim().isEmpty) return 'Enter your email';
final ok = RegExp(r'^[\w.+-]+@[\w-]+(\.[\w-]+)+$').hasMatch(v.trim());
return ok ? null : 'Enter a valid email';
}
static String? Function(String?) minLength(int n) =>
(v) => (v == null || v.length < n) ? 'Use at least $n characters' : null;
}
// Usage
TextFormField(validator: Validators.email)
TextFormField(validator: Validators.minLength(8))
When to show errors #
autovalidateMode | Errors appear |
|---|---|
disabled (default) | Only when you call validate() |
onUserInteraction | As soon as the user edits a field |
always | Immediately, even on untouched fields |
A friendly pattern: validate on submit the first time, then switch to onUserInteraction so errors clear as the user fixes them.
Form(
key: _formKey,
autovalidateMode: _submitted
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
child: ...,
)
Saving without controllers #
onSaved is called for every field when you call save(). It avoids one controller per field.
String _name = '';
String _city = '';
TextFormField(
decoration: const InputDecoration(labelText: 'Name'),
validator: Validators.required,
onSaved: (value) => _name = value!.trim(),
)
void _submit() {
final form = _formKey.currentState!;
if (!form.validate()) return;
form.save();
debugPrint('$_name from $_city');
}
form.reset() returns every field to its initial value.
Submitting to a server #
bool _saving = false;
String? _serverError;
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_saving = true;
_serverError = null;
});
try {
await api.register(_email.text.trim(), _password.text);
if (!mounted) return;
Navigator.of(context).pop();
} catch (e) {
if (!mounted) return;
setState(() => _serverError = 'Could not create the account. Try again.');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Disable the button while saving (onPressed: _saving ? null : _submit) and show _serverError above it.
Other form fields #
DropdownButtonFormField, CheckboxListTile wrapped in a FormField<bool>, and your own widgets built on FormField<T> all take part in validation the same way.
Checklist for a good form #
- A label on every field, not only a hint that disappears.
- The right keyboard, capitalisation and autofill hints.
- “Next” moves to the next field, “Done” submits.
- Error messages say how to fix the problem.
- The form scrolls, so the keyboard never covers a field.
- Trim spaces from emails and names before using them.
Try it yourself #
Build a “contact us” form with name, email, phone (digits only, exactly 10) and message (at least 20 characters). Show errors on submit, then live as the user corrects them, and clear the form after a successful submit.