Sign-up, checkout and settings screens all need validated input with clear error messages.
A `Form` groups TextFormFields (and other FormFields) so you can validate and save them together. Give the form a `GlobalKey<FormState>` stored in a State object, so you can reach the form's state from a button.
Each TextFormField takes a `validator`: a function that receives the current value (String?) and returns an error message, or null when valid. On submit:
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
// send data
}validate() runs every validator, shows the messages under the fields and returns whether all passed. onSaved callbacks then copy values into your model when you call save().
UX details:
autovalidateMode: AutovalidateMode.onUserInteractionvalidates each field after the user first edits it, instead of only on submit.- Use
keyboardType: TextInputType.emailAddress,textInputAction: TextInputAction.nextandautofillHintsfor faster input. - Disable the submit button while a request is in flight and show progress.
- Server-side errors (e.g. "email already taken") can be shown by storing them in state and returning them from the validator, or via
InputDecoration.errorText.
Keep validation logic in plain Dart functions so it's easy to unit test. Wrap the form in a SingleChildScrollView so the keyboard doesn't hide fields.
Example
import 'package:flutter/material.dart';
String? validateEmail(String? v) =>
(v == null || !RegExp(r'^\S+@\S+\.\S+$').hasMatch(v)) ? 'Enter a valid email' : null;
class SignUpForm extends StatefulWidget {
const SignUpForm({super.key});
@override
State<SignUpForm> createState() => _SignUpFormState();
}
class _SignUpFormState extends State<SignUpForm> {
final _formKey = GlobalKey<FormState>();
String _email = '';
@override
Widget build(BuildContext context) => Form(
key: _formKey, autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(children: [
TextFormField(decoration: const InputDecoration(labelText: 'Email'),
validator: validateEmail, onSaved: (v) => _email = v!.trim()),
TextFormField(obscureText: true, decoration: const InputDecoration(labelText: 'Password'),
validator: (v) => (v ?? '').length < 8 ? 'At least 8 characters' : null),
FilledButton(child: const Text('Sign up'), onPressed: () {
if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); debugPrint('ok $_email'); }
}),
]),
);
}Remember
- A GlobalKey<FormState> lets you call validate() and save()
- Validators return an error string or null
- Keep validation in plain functions you can unit test
Watch out for
- Creating the GlobalKey inside
build, which makes a new form state on every rebuild.