Forms and Text Input
Capturing user input with TextField and TextEditingController, and validating a form before submission.
2 min de lecture
Almost every app needs to collect input at some point — a login screen, a search box, a settings field. This lesson covers TextField for individual inputs and Form for validating several fields together.
TextField and TextEditingController
A TextField on its own displays an editable box, but to actually read what the user typed, you need a TextEditingController:
class SearchBox extends StatefulWidget {
const SearchBox({super.key});
@override
State<SearchBox> createState() => _SearchBoxState();
}
class _SearchBoxState extends State<SearchBox> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Search',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
print('Searching for: $value');
},
);
}
}Two details matter here. First, _controller.text is how you read the current value at any point (for example, when a button elsewhere is pressed). Second, and easy to forget: controllers must be disposed in the State's dispose method — skipping this leaks resources every time this widget is removed from the tree.
Reacting to every keystroke
onChanged fires on every keystroke, which is what you want for live search or a character counter:
TextField(
onChanged: (value) {
setState(() {
_characterCount = value.length;
});
},
)Prefer onSubmitted (fires once, when the user finishes) over onChanged for anything expensive to run, like an API call — running a network request on every keystroke without additional debouncing wastes requests and can make the UI feel laggy.
Grouping fields with Form
For multiple fields that need validation before submission, wrap them in a Form with a GlobalKey:
class SignupForm extends StatefulWidget {
const SignupForm({super.key});
@override
State<SignupForm> createState() => _SignupFormState();
}
class _SignupFormState extends State<SignupForm> {
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
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()) {
print('Form is valid, submit it');
}
},
child: const Text('Sign Up'),
),
],
),
);
}
}TextFormField is TextField with validation built in. Each field's validator returns an error message string (shown under the field automatically) or null if the value is valid. Calling _formKey.currentState!.validate() runs every field's validator at once and returns true only if all of them passed — this is the standard way to block submission until the whole form is valid, without manually tracking each field's error state yourself.
The takeaway
TextEditingController for reading and controlling a single field's value, onChanged/onSubmitted for reacting to input, and Form/TextFormField/validator for multi-field validation — together these cover the large majority of input handling you'll need before reaching for a dedicated form library.