Forms & Input Handling
Almost every app eventually needs to collect something from the user — a search query, a login, a comment. Flutter's input widgets are, like everything else, just widgets, and they compose with the same layout tools you already know. This lesson covers the basic TextField, how to read and control its value with a TextEditingController, and the Form system for validating several fields together before submission.
TextField: The Basic Text Input
TextField is the fundamental widget for free-form text entry. On its own it just displays a text box and updates itself as the user types — for most real use cases you'll pair it with a controller (below) so your own code can read what was typed.
TextField(
decoration: InputDecoration(
labelText: 'Your name',
hintText: 'e.g. Jane Doe',
border: OutlineInputBorder(),
),
)
TextEditingController: Reading and Controlling the Value
A TextEditingController attaches to a TextField
and gives your code direct access to its text — you can read controller.text at any time,
set it programmatically, and even listen for every change. Because it's a long-lived object tied to the
widget's state, it must be created in initState and, critically, disposed
in dispose — skipping this leaks memory every time the widget is rebuilt or removed from
the tree.
class NameField extends StatefulWidget {
const NameField({super.key});
@override
State<NameField> createState() => _NameFieldState();
}
class _NameFieldState extends State<NameField> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose(); // always dispose controllers to avoid memory leaks
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(labelText: 'Name'),
),
ElevatedButton(
onPressed: () {
// Read the current value at any time
print('Name entered: ${_controller.text}');
},
child: const Text('Print Name'),
),
],
);
}
}
dispose() is one of the most common Flutter memory leak bugs — the controller (and its internal listeners) stays alive in memory long after the widget that created it is gone, if nothing ever cleans it up.The Form Widget and GlobalKey<FormState>
When you have several fields that need to be validated together — say, before enabling a "Submit"
button — wrap them in a Form widget. A Form
doesn't render anything visually by itself; it coordinates the validation state of every
TextFormField beneath it. You reach into that coordinated state using a
GlobalKey<FormState>, which you create once and attach to the
Form's key property.
final _formKey = GlobalKey<FormState>();
Form(
key: _formKey,
child: Column(
children: [
// TextFormField widgets go here
],
),
)
TextFormField and Validators
TextFormField is the Form-aware version of
TextField — it accepts a validator function that receives
the field's current text and returns an error message String if it's
invalid, or null if it's fine. Calling
_formKey.currentState!.validate() runs every field's validator at once and returns
true only if all of them passed — Flutter automatically displays any
returned error messages under the relevant fields.
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
if (!value.contains('@')) {
return 'Enter a valid email';
}
return null; // null means "valid"
},
)
// Triggering validation, typically from a submit button
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// all fields passed their validators
print('Form is valid, submitting...');
}
},
child: const Text('Submit'),
)
onChanged and onSubmitted
Both TextField and TextFormField expose
two useful callbacks: onChanged fires on every keystroke, useful for live search-as-you-type
or a live character counter, while onSubmitted fires once, when the user presses the
keyboard's "done"/"go" action.
TextField(
onChanged: (value) {
print('User is typing: $value');
},
onSubmitted: (value) {
print('User submitted: $value');
},
)
Other Common Input Widgets
Text isn't the only kind of input. Flutter ships several dedicated widgets for the other common patterns you'll need in almost any form.
| Widget | Used for |
|---|---|
| Checkbox | A single on/off box, typically paired with a Text label ("I agree to the terms") |
| Switch | An on/off toggle styled like a physical switch — commonly used for settings screens |
| Radio | One choice from a small fixed set, where only one option can be selected at a time (grouped by a shared groupValue) |
| DropdownButton | A tappable field that expands into a list of choices, good for longer option lists |
// A Switch example
Switch(
value: _notificationsEnabled,
onChanged: (bool value) {
setState(() { _notificationsEnabled = value; });
},
)
// A DropdownButton example
DropdownButton<String>(
value: _selectedCountry,
items: ['India', 'USA', 'UK']
.map((c) => DropdownMenuItem(value: c, child: Text(c)))
.toList(),
onChanged: (value) {
setState(() { _selectedCountry = value; });
},
)
Full Example: A Simple Validated Form
Putting it all together — a name field, an email field with validation, and a submit button that only proceeds once everything passes.
class SignupForm extends StatefulWidget {
const SignupForm({super.key});
@override
State<SignupForm> createState() => _SignupFormState();
}
class _SignupFormState extends State<SignupForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Name'),
validator: (value) =>
(value == null || value.isEmpty) ? 'Name is required' : null,
),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || !value.contains('@')) {
return 'Enter a valid email';
}
return null;
},
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Signed up as ${_emailController.text}')),
);
}
},
child: const Text('Sign Up'),
),
],
),
);
}
}