Forms, TextEditingController and validation
Read what the user typed, validate every field with one call, and dispose controllers so the app does not leak memory.
A login or sign-up form is the most common Flutter take-home task. It tests whether you know the difference between TextField and TextFormField, how to get the typed text, and whether you clean up after yourself. The pattern below covers all three.
The pieces
Formgroups fields and owns aGlobalKey<FormState>so you can validate them all at once.TextFormFieldis aTextFieldwithvalidatorandonSavedadded. PlainTextFieldcannot validate through the form.TextEditingControllerholds the current text and lets you read or set it from code.
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
final email = _emailController.text.trim();
debugPrint('Signing in as $email');
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || !value.contains('@')) {
return 'Enter a valid email';
}
return null;
},
),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
validator: (value) =>
(value ?? '').length < 8 ? 'Use at least 8 characters' : null,
),
ElevatedButton(onPressed: _submit, child: const Text('Sign in')),
],
),
);
}
}How validation runs
validate() calls every field's validator. A validator returns null when the value is fine and an error string otherwise; the string appears under the field automatically. validate() returns true only when all validators returned null. Set autovalidateMode: AutovalidateMode.onUserInteraction on the form if you want errors to update as the user types after their first attempt.
Reading and reacting to input
Use the controller when you need the value at submit time. Use onChanged when the screen should react while typing, for example enabling the button only when both fields have text. A second worked example: a search box that filters a list.
TextField(
decoration: const InputDecoration(hintText: 'Search jobs'),
onChanged: (text) {
setState(() {
_query = text.toLowerCase();
});
},
)Common mistakes and a practice habit
- Creating the
GlobalKeyor controller insidebuild. Each rebuild makes a new one and the form loses its state. - Forgetting
dispose(), which leaks the controller and its listeners. - Using a
TextFieldinside aFormand expectingvalidate()to check it. - Forgetting
trim(), so a trailing space fails an email check.
Practice habit: build this form from a blank file, then add a confirm-password field whose validator compares against the first controller's text. Doing it three times without notes is the goal.
More in Web and app