Forms & Validation
Sign-up screens, checkout pages, feedback forms and "add expense" screens all collect input. A good form tells users what is wrong and where, before anything is saved: "Enter a 10-digit mobile number" next to the mobile field is far better than a generic "Invalid input".
Flutter's Form widget groups fields so you can validate, save and reset them together. In this lesson you will build a complete admission form with text fields, a dropdown and a date picker, and you will write validators as plain Dart functions so you can test them.
Form, GlobalKey and TextFormField
A Form wraps several TextFormFields (and other form fields). Each field has a validator function: it receives the current text and returns an error message, or null if the value is fine. A GlobalKey<FormState> gives you access to the form so you can call validate().
import 'package:flutter/material.dart';
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _mobile = TextEditingController();
@override
void dispose() {
_mobile.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('OTP sent to ${_mobile.text}')),
);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _mobile,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(labelText: 'Mobile number', prefixText: '+91 '),
validator: (value) {
final v = value?.trim() ?? '';
if (v.isEmpty) return 'Enter your mobile number';
if (!RegExp(r'^[6-9]\d{9}$').hasMatch(v)) return 'Enter a valid 10-digit mobile number';
return null;
},
),
FilledButton(onPressed: _submit, child: const Text('Send OTP')),
],
),
);
}
}The form shows a "Mobile number" field with "+91 " in front of the cursor and a Send OTP button. Tapping the button with an empty field shows "Enter your mobile number" in red under the field. Typing 12345 shows "Enter a valid 10-digit mobile number". With 9876543210, the error disappears and a snack bar says "OTP sent to 9876543210".
validate() runs every field's validator, shows the messages, and returns true only if all of them returned null. currentState! is safe here because the key is attached to a Form that is on screen.
Validators are plain Dart
A validator is just a function String? Function(String?). Keep validators in their own file, without any Flutter imports, so you can reuse them in many forms and test them quickly. This file is pure Dart, so you can run it with dart run:
String? validateName(String? value) {
final v = value?.trim() ?? '';
if (v.isEmpty) return 'Name is required';
if (v.length < 2) return 'Name is too short';
return null;
}
String? validatePincode(String? value) {
final v = value?.trim() ?? '';
if (!RegExp(r'^[1-9]\d{5}$').hasMatch(v)) return 'Enter a 6-digit PIN code';
return null;
}
String? Function(String?) minAmount(int min) {
return (value) {
final amount = int.tryParse(value ?? '');
if (amount == null) return 'Enter an amount in rupees';
if (amount < min) return 'Minimum amount is ₹$min';
return null;
};
}
void main() {
print(validateName(' '));
print(validateName('Isha'));
print(validatePincode('012345'));
print(validatePincode('302001'));
final feeValidator = minAmount(500);
print(feeValidator('abc'));
print(feeValidator('200'));
print(feeValidator('1500'));
}Name is required null Enter a 6-digit PIN code null Enter an amount in rupees Minimum amount is ₹500 null
minAmount(500) returns a validator function, a closure that remembers min (see Functions). In a form you write validator: minAmount(500). Indian PIN codes do not start with 0, which is why the pattern begins with [1-9].
When to show errors: autovalidateMode
By default, errors appear only when you call validate(). Showing an error while the user is still typing their first character is annoying; never showing it until submit can be frustrating too. autovalidateMode: AutovalidateMode.onUserInteraction is a good middle ground: a field is checked as soon as the user has changed it, and re-checked on every change after that. You can set it on the whole Form or on single fields.
A complete form: formatters, dropdown and date
Real forms mix several kinds of fields. Here is an admission form for a coaching institute:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
enum Batch { morning, evening, weekend }
class AdmissionForm extends StatefulWidget {
const AdmissionForm({super.key});
@override
State<AdmissionForm> createState() => _AdmissionFormState();
}
class _AdmissionFormState extends State<AdmissionForm> {
final _formKey = GlobalKey<FormState>();
String _name = '';
Batch? _batch;
DateTime? _startDate;
String _summary = '';
Future<void> _pickDate() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _startDate ?? now,
firstDate: now,
lastDate: now.add(const Duration(days: 90)),
);
if (picked != null) setState(() => _startDate = picked);
}
void _submit() {
final form = _formKey.currentState!;
if (!form.validate() || _startDate == null) {
if (_startDate == null) setState(() => _summary = 'Please choose a start date');
return;
}
form.save();
setState(() {
final d = _startDate!;
_summary = '$_name joins the ${_batch!.name} batch on ${d.day}/${d.month}/${d.year}';
});
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Student name'),
textInputAction: TextInputAction.next,
validator: (v) => (v == null || v.trim().length < 2) ? 'Enter the full name' : null,
onSaved: (v) => _name = v!.trim(),
),
TextFormField(
decoration: const InputDecoration(labelText: 'Parent mobile'),
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (v) => (v ?? '').length == 10 ? null : 'Enter 10 digits',
),
DropdownButtonFormField<Batch>(
initialValue: _batch,
decoration: const InputDecoration(labelText: 'Batch'),
items: const [
DropdownMenuItem(value: Batch.morning, child: Text('Morning (7 to 9 am)')),
DropdownMenuItem(value: Batch.evening, child: Text('Evening (5 to 7 pm)')),
DropdownMenuItem(value: Batch.weekend, child: Text('Weekend')),
],
onChanged: (b) => setState(() => _batch = b),
validator: (b) => b == null ? 'Choose a batch' : null,
),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(_startDate == null
? 'No start date chosen'
: 'Starts on ${_startDate!.day}/${_startDate!.month}/${_startDate!.year}'),
trailing: OutlinedButton(onPressed: _pickDate, child: const Text('Choose date')),
),
FilledButton(onPressed: _submit, child: const Text('Submit admission')),
Text(_summary),
],
),
);
}
}The form has a name field, a mobile field that accepts only digits and stops at 10 characters, a "Batch" dropdown, a row with "No start date chosen" and a Choose date button, and a Submit admission button. Tapping submit with empty fields shows the error under each field. After filling everything in and choosing a date in the calendar dialog, the last line shows, for example, "Kabir joins the evening batch on 8/12/2026".
inputFormatterschange what the user can type:FilteringTextInputFormatter.digitsOnlyblocks letters, andLengthLimitingTextInputFormatter(10)stops at 10 characters. Formatters prevent bad input; validators explain what is still wrong. Use both.onSavedruns for each field when you callform.save(), which is a tidy way to copy values out of a form without a controller per field.DropdownButtonFormFieldtakesinitialValue(the oldervalueparameter was deprecated in Flutter 3.35) and can have its own validator.showDatePickershows the Material calendar and returns the chosenDateTime, ornullif the user cancels.initialDateis the day selected when the calendar opens; without it, no day is selected until the user taps one.textInputAction: TextInputAction.nextshows a "next" key on the keyboard that moves to the next field.
Other useful field options
obscureText: truehides passwords; add an icon button that toggles it so users can check what they typed.maxLengthshows a character counter;maxLinesmakes a field taller for comments.form.reset()clears all fields back to their initial values.FocusScope.of(context).unfocus()closes the keyboard, for example after submitting.enabled: falsemakes a field read-only, such as a pre-filled roll number.
Common mistakes
- Returning an empty string from a validator.
''counts as an error with no text. Returnnullfor valid input. - Creating the
GlobalKeyinsidebuild. A new key on every rebuild resets the form. Create it once as a field of theState. - Only validating on submit for long forms. Use
AutovalidateMode.onUserInteractionso users see problems while they fill in the form. - Trusting
int.parseon form text. Useint.tryParseinside validators.
Key points
- Wrap fields in a
Formwith aGlobalKey<FormState>; callvalidate(),save()andreset()through it. - A validator returns an error message, or
nullwhen the value is valid. - Keep validators as plain Dart functions so they can be reused and unit-tested.
AutovalidateMode.onUserInteractionshows errors after the user has touched a field.inputFormattersrestrict typing;DropdownButtonFormFieldusesinitialValue;showDatePickerreturns aDateTime?.- Always validate again on the server.
Exercise
Write two pure Dart validators and test them with print: validateEmail, which requires an @ and a . after it (return 'Enter a valid email' otherwise), and validatePassword, which requires at least 8 characters and at least one digit (return a specific message for each problem). Then use both in a TextFormField-based sign-up form, with the password field hidden.
Show solution
String? validateEmail(String? value) {
final v = value?.trim() ?? '';
final at = v.indexOf('@');
if (at < 1 || !v.substring(at).contains('.')) return 'Enter a valid email';
return null;
}
String? validatePassword(String? value) {
final v = value ?? '';
if (v.length < 8) return 'Use at least 8 characters';
if (!v.contains(RegExp(r'\d'))) return 'Add at least one number';
return null;
}
void main() {
print(validateEmail('ananya.gmail.com'));
print(validateEmail('ananya@gmail.com'));
print(validatePassword('chai123'));
print(validatePassword('masalachai'));
print(validatePassword('masalachai2'));
}Enter a valid email null Use at least 8 characters Add at least one number null
In the form, use them directly: TextFormField(validator: validateEmail, keyboardType: TextInputType.emailAddress) and TextFormField(validator: validatePassword, obscureText: true). Because the validators have no Flutter imports, you can also cover them with fast unit tests later.
Quick Quiz
1. What should a validator return when the value is valid?
Any non-null string, even an empty one, is treated as an error.
2. What does _formKey.currentState!.validate() do?
validate() checks all fields in the form.
3. Which option stops a user from typing letters into a number field?
Formatters filter the text as it is typed.
4. Why should the GlobalKey<FormState> be a field of the State, not created in build?
The key must stay the same across rebuilds.
Learn Flutter & Dart with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
