Forms & Text Input
Collecting User Data with Validation
Open interactive version (quiz + challenge)Real-world analogy
A Form in Flutter is like a paper application form at a bank. Each TextFormField is a blank line where you write information (name, email, phone). Validation is like the bank teller checking your form before accepting it — 'You left the email blank!' or 'That phone number is too short!'. The GlobalKey is like the form's tracking number that lets the bank find and process your specific form.
What is it?
Forms in Flutter use the Form widget with a GlobalKey to group TextFormField inputs for collective validation. Each TextFormField has a validator function that returns an error string or null. TextEditingController provides programmatic access to field values. InputDecoration styles the fields with labels, icons, and borders.
Real-world relevance
In team_mvp_kit, the login screen, registration screen, and profile edit screen all use Form with TextFormField. The project defines reusable validator functions for email, password strength, and required fields. TextEditingControllers read values on submit and pass them to the authentication BLoC. FocusNodes provide smooth keyboard navigation between fields.
Key points
- TextFormField Basics — TextFormField is a text input field designed to work inside a Form widget. It displays a text box where users type. It supports decoration (labels, hints, icons), input types (keyboard layouts), and validation. Use it for any text input: name, email, password, search, etc.
- Form Widget and GlobalKey — Form groups multiple TextFormField widgets together so you can validate, save, or reset them all at once. Attach a GlobalKey to access the form's state. Call formKey.currentState!.validate() to run all field validators simultaneously.
- Validation Functions — The validator property takes a function that receives the field value and returns an error message string if invalid, or null if valid. Validators can check for empty values, minimum length, email format, matching passwords, and any custom business logic. They run when you call form.validate().
- TextEditingController — TextEditingController gives you programmatic access to a text field's value. You can read the current text, set it to a new value, clear it, or listen for changes. Always create controllers in initState or as final fields, and dispose of them in dispose() to prevent memory leaks.
- InputDecoration Styling — InputDecoration controls the appearance of a TextFormField: labelText (floating label), hintText (placeholder), prefixIcon, suffixIcon, border styles (OutlineInputBorder, UnderlineInputBorder), error styling, filled background, and content padding. A well-decorated field guides users on what to enter.
- Password Visibility Toggle — For password fields, set obscureText: true to hide the input with dots. Add a suffixIcon with an IconButton that toggles a boolean state variable to switch between Icons.visibility and Icons.visibility_off. This common pattern lets users verify their password before submitting.
- FocusNode Control — FocusNode controls which text field has keyboard focus. Use it to auto-focus a field when a screen opens, move focus to the next field when the user presses 'Next' on the keyboard (textInputAction), or unfocus all fields when the user taps outside (FocusScope.of(context).unfocus).
- AutovalidateMode — AutovalidateMode controls when validation runs. AutovalidateMode.disabled (default) only validates when you call form.validate(). AutovalidateMode.onUserInteraction validates as the user types or leaves a field. Set it on the Form or individual TextFormField to give real-time validation feedback.
Code example
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 _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
final _passwordFocus = FocusNode();
bool _obscurePassword = true;
bool _isLoading = false;
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
_passwordFocus.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
setState(() => _isLoading = true);
// Send _emailCtrl.text and _passwordCtrl.text to auth
Future.delayed(const Duration(seconds: 2), () {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Login successful!')),
);
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(_passwordFocus);
},
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
if (!value.contains('@')) {
return 'Enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordCtrl,
focusNode: _passwordFocus,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Login'),
),
),
],
),
),
),
);
}
}Line-by-line walkthrough
- 1. Import the Material package
- 2.
- 3. Define LoginForm as a StatefulWidget since form state changes
- 4.
- 5.
- 6. Create the State class
- 7. GlobalKey to access the Form's state for validation
- 8. TextEditingController for the email field
- 9. TextEditingController for the password field
- 10. FocusNode to control password field focus
- 11. Boolean to toggle password visibility
- 12. Boolean to track loading state
- 13.
- 14. Dispose controllers and focus nodes to free memory
- 15.
- 16.
- 17.
- 18.
- 19. The submit method handles form submission
- 20. Check if all validators pass
- 21. Set loading to true to show progress indicator
- 22. Read email and password from controllers
- 23. Simulate a network delay of 2 seconds
- 24. Set loading back to false
- 25. Show a success SnackBar
- 26.
- 27.
- 28.
- 29.
- 30. Build method creates the UI
- 31. Scaffold with an AppBar titled Login
- 32. Body wrapped in 24px padding
- 33. Form widget groups all fields with the form key
- 34. Column stretches children to full width
- 35.
- 36. Email TextFormField
- 37. Attach the email controller
- 38. Set keyboard to email type with @ shortcut
- 39. Next action moves to password field
- 40. On submit, move focus to password field
- 41.
- 42. InputDecoration with label, email icon, and outlined border
- 43.
- 44.
- 45.
- 46. Validator: check empty then check for @ symbol
- 47. Return error strings or null for valid
- 48.
- 49.
- 50.
- 51. 16px gap between fields
- 52.
- 53. Password TextFormField
- 54. Attach password controller and focus node
- 55. obscureText hides the password text
- 56. Done action triggers submit
- 57. On keyboard done press, call submit
- 58. InputDecoration with label, lock icon, border
- 59.
- 60. suffixIcon toggles between visibility icons
- 61.
- 62. Toggle the obscure boolean on press
- 63.
- 64.
- 65.
- 66.
- 67.
- 68. Validator: check empty then check minimum length
- 69.
- 70.
- 71.
- 72.
- 73. 24px gap before the submit button
- 74.
- 75. 48px height button for comfortable tap target
- 76. Disable button while loading by setting onPressed to null
- 77. Show CircularProgressIndicator when loading
- 78.
- 79.
- 80.
- 81. Otherwise show Login text
- 82.
- 83.
- 84.
- 85.
- 86.
Spot the bug
class _FormState extends State<MyForm> {
final _formKey = GlobalKey<FormState>();
final _ctrl = TextEditingController();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: TextFormField(controller: _ctrl),
);
}
}Need a hint?
What is missing that could cause a memory leak?
Show answer
The TextEditingController is never disposed. When the widget is removed from the tree, the controller keeps listening in memory. Fix: add a dispose() method that calls _ctrl.dispose() and super.dispose() to free the controller's resources.
Explain like I'm 5
Imagine you are filling out a permission slip for a school trip. Each blank line is like a TextFormField where you write your name, your parent's phone number, and your allergies. The teacher (validator) checks every line before accepting the form. If you forgot to write your name, the teacher circles that line in red and says 'Please fill this in!' That red error message is exactly what TextFormField's validator does. The GlobalKey is like the teacher knowing which permission slip is yours among all the students.
Fun fact
Flutter's Form widget is inspired by HTML forms in web development. Just like HTML forms have an action and input elements with validation attributes, Flutter Forms group TextFormFields and validate them collectively. The GlobalKey concept is unique to Flutter though — it is how widgets talk to their state objects from outside!
Hands-on challenge
Build a registration form with four fields: Name (required, min 2 characters), Email (required, must contain @), Password (required, min 8 characters, must contain a number), and Confirm Password (must match password). Add FocusNode navigation between fields, a password visibility toggle, AutovalidateMode.onUserInteraction, and a submit button that shows a loading spinner while processing.
More resources
- Forms - Flutter Cookbook (Flutter Official)
- TextFormField Class (Flutter API)
- Form Validation (Flutter Official)