Flutter: Using TextField and TextEditingController
9 min read

TextField is the basic widget for getting text from the user: a search box, a chat input, a phone number, a password. Putting one on the screen is easy; the real work is reading and managing its text, opening the right keyboard, moving focus between fields and blocking invalid input up front. In this post we build up everything a text field needs, from the TextEditingController lifecycle to FocusNode, from inputFormatters to password fields, and finally combine it all in a sign-up screen. Validation and submitting several fields together is the topic of the Form post; here we focus on the field underneath it.
Basic Usage: TextEditingController
The standard way to reach a field's text from code is a TextEditingController. Because the controller is an object that holds listeners, it is created once in the State class and closed in dispose():
import 'package:flutter/material.dart';
class NameInput extends StatefulWidget {
const NameInput({super.key});
@override
State<NameInput> createState() => _NameInputState();
}
class _NameInputState extends State<NameInput> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose(); // You created it, you close it
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(labelText: 'Your name'),
),
FilledButton(
onPressed: () {
final name = _controller.text.trim();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hello, $name')),
);
},
child: const Text('Say hi'),
),
],
);
}
}The dispose rule isn't specific to TextEditingController; it applies to everything you create yourself, such as FocusNode, AnimationController and ScrollController. I explain why we go through initState and dispose in detail in the widget lifecycle post.
What Can You Do with a Controller?
A controller isn't only for reading; it also lets you manage the field from code:
// Read
final current = controller.text;
// Clear
controller.clear();
// Provide initial text
final cityController = TextEditingController(text: 'Chicago');
// Change the text and put the cursor at the end
const newText = 'Boston';
controller.value = const TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: newText.length),
);
// Select all text
controller.selection = TextSelection(
baseOffset: 0,
extentOffset: controller.text.length,
);controller.text = 'Boston' also changes the text, but it resets the selection (the cursor position) to an invalid value. If it matters where the cursor ends up, set value with both the text and the selection as shown above.
TextEditingController is actually a ValueNotifier<TextEditingValue>, so it can be listened to. That makes it easy to rebuild only the relevant part whenever the field changes. For example, a "clear" button that appears only when there's text:
ListenableBuilder(
listenable: controller,
builder: (context, child) => TextField(
controller: controller,
decoration: InputDecoration(
hintText: 'Search',
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Clear',
onPressed: controller.clear,
),
),
),
)One detail: controller listeners fire not only when the text changes but also when the cursor or selection moves. If your question is "did the text change?", keep the previous value and compare.
InputDecoration: How the Field Looks
Everything around the field, such as the label, hint, icons and error text, is set through InputDecoration:
const TextField(
decoration: InputDecoration(
labelText: 'Email',
hintText: '[email protected]',
helperText: 'We send the receipt to this address',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
)| Property | What it does |
|---|---|
labelText |
Label shown inside the empty field, floats above it when focused |
hintText |
Example text shown while the field is empty |
helperText |
Small explanation below the field |
errorText |
Red error text below; when not null, the field switches to its error look |
prefixIcon, suffixIcon |
Icon or button at the start and end of the field |
prefixText, suffixText |
Fixed text, for example +1 or USD |
border |
OutlineInputBorder (outline) or UnderlineInputBorder (underline) |
counterText |
Text of the maxLength counter; '' hides the counter |
labelText or hintText? Simple: hintText disappears as soon as the user starts typing, while labelText moves up and stays visible. Put what the field is in labelText and a format example in hintText.
You can also show errors without a Form using errorText: keep a String? state variable, assign the check result to it and pass InputDecoration(errorText: _usernameError).
The Keyboard: keyboardType and textInputAction
keyboardType decides which keyboard opens on the phone; textInputAction decides the keyboard's bottom-right key:
keyboardType |
Use |
|---|---|
TextInputType.emailAddress |
Brings @ and the dot forward |
TextInputType.number |
Numeric keyboard |
TextInputType.numberWithOptions(decimal: true) |
Numeric keyboard with a decimal separator |
TextInputType.phone |
Phone keypad |
TextInputType.url |
Brings keys like / and . forward |
TextInputType.multiline |
Enter inserts a new line (with maxLines: null) |
The most common textInputAction values are next (go to the next field), done, search and send. Flutter's default behavior: pressing next moves focus to the next field in reading order; completion keys like done, search and send give up focus and close the keyboard.
Important: keyboardType only changes how the keyboard looks; it doesn't restrict input. The user can paste, or type any character with a hardware keyboard on a tablet or desktop. Actually restricting input is the job of inputFormatters.
inputFormatters: Blocking Invalid Input Up Front
Formatters filter every edit before the text is written to the field. The built-in ones come from package:flutter/services.dart:
import 'package:flutter/services.dart';
// Digits only, at most 10
TextField(
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
)
// A username that can't contain spaces
TextField(
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
)
// A multi-line note field with a counter
const TextField(
maxLength: 280,
maxLines: null,
keyboardType: TextInputType.multiline,
)With FilteringTextInputFormatter.allow you can permit only characters that match a given pattern. The difference between maxLength and LengthLimitingTextInputFormatter is visibility: the first limits the length and shows a counter like 12/280 below the field, the second limits silently. Formatters run before onChanged, so the value onChanged receives has already been filtered.
Password Fields: obscureText
In a password field the characters are hidden with obscureText: true. Adding a show/hide button so users can check what they typed is a good habit:
class PasswordField extends StatefulWidget {
const PasswordField({super.key, required this.controller});
final TextEditingController controller;
@override
State<PasswordField> createState() => _PasswordFieldState();
}
class _PasswordFieldState extends State<PasswordField> {
bool _obscure = true;
@override
Widget build(BuildContext context) {
return TextField(
controller: widget.controller,
obscureText: _obscure,
enableSuggestions: false,
autocorrect: false,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
tooltip: _obscure ? 'Show password' : 'Hide password',
onPressed: () => setState(() => _obscure = !_obscure),
),
),
);
}
}enableSuggestions: false and autocorrect: false keep the keyboard from learning the password as a word suggestion or correcting it. autofillHints tells the operating system's password manager "this is a password field"; that's how autofilling a saved password works.
onChanged, onSubmitted and onEditingComplete
All three callbacks say "the user did something", but they fire at different moments:
onChanged: on every keystroke, deletion and paste. For live search filtering, character counters, enabling a button.onSubmitted: when the user presses the keyboard's action key (done, search, send), with the field's final value. For starting a search, sending a message, moving to the next field.onEditingComplete: on the action key, beforeonSubmittedand without a value. If you provide it, Flutter's default behavior (giving up focus or moving to the next field) no longer runs; that job becomes yours. Most of the time you don't need it.
TextField(
textInputAction: TextInputAction.search,
onChanged: (value) => _filterList(value), // Filters the list on every letter
onSubmitted: (value) => _saveRecentSearch(value), // Saved on the search key
)A trap: onChanged only fires for changes the user makes. It isn't called for changes you make from code with controller.text = ... or controller.clear(). If you need to react to both kinds, listen to the controller.
Managing Focus with FocusNode
Focus decides which field the keyboard types into. textInputAction: TextInputAction.next already moves focus in most forms, but the order is the on-screen reading order. To jump to a specific field, to do something when a field gains or loses focus, or to close the keyboard from code, you need a FocusNode:
class AddressForm extends StatefulWidget {
const AddressForm({super.key});
@override
State<AddressForm> createState() => _AddressFormState();
}
class _AddressFormState extends State<AddressForm> {
final _cityFocus = FocusNode();
@override
void dispose() {
_cityFocus.dispose(); // FocusNode gets disposed too
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
textInputAction: TextInputAction.next,
onSubmitted: (_) => _cityFocus.requestFocus(), // Jump where you want
),
TextField(
focusNode: _cityFocus,
// On mobile, tapping outside doesn't close the keyboard by itself
onTapOutside: (_) => FocusScope.of(context).unfocus(),
),
],
);
}
}To close the keyboard from anywhere you can also use FocusManager.instance.primaryFocus?.unfocus(). If you want to change the UI based on a field's focus, read focusNode.hasFocus; since FocusNode is also a ChangeNotifier, it can be listened to with ListenableBuilder.
Mini Scenario: A Sign-Up Screen
Now let's put the pieces together: a screen with name, phone and password fields; you move through it with "next" on the keyboard; the phone field accepts only 10 digits; the password can be shown and hidden; and the button stays disabled until the fields are valid:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class SignUpPage extends StatefulWidget {
const SignUpPage({super.key});
@override
State<SignUpPage> createState() => _SignUpPageState();
}
class _SignUpPageState extends State<SignUpPage> {
final _nameController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _phoneFocus = FocusNode();
final _passwordFocus = FocusNode();
bool _obscure = true;
// Re-evaluate the button when any of the three controllers changes
late final Listenable _fields = Listenable.merge([
_nameController,
_phoneController,
_passwordController,
]);
bool get _canSubmit =>
_nameController.text.trim().length >= 2 &&
_phoneController.text.length == 10 &&
_passwordController.text.length >= 8;
@override
void dispose() {
_nameController.dispose();
_phoneController.dispose();
_passwordController.dispose();
_phoneFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
void _submit() {
if (!_canSubmit) return;
FocusScope.of(context).unfocus(); // Close the keyboard
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Welcome, ${_nameController.text.trim()}')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create account')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _nameController,
autofocus: true,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.name],
onSubmitted: (_) => _phoneFocus.requestFocus(),
decoration: const InputDecoration(
labelText: 'Full name',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _phoneController,
focusNode: _phoneFocus,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.telephoneNumber],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
onSubmitted: (_) => _passwordFocus.requestFocus(),
decoration: const InputDecoration(
labelText: 'Phone',
hintText: '5551234567',
prefixText: '+1 ',
prefixIcon: Icon(Icons.phone_outlined),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
focusNode: _passwordFocus,
obscureText: _obscure,
enableSuggestions: false,
autocorrect: false,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.newPassword],
onSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: 'Password',
helperText: 'At least 8 characters',
prefixIcon: const Icon(Icons.lock_outline),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
tooltip: _obscure ? 'Show password' : 'Hide password',
onPressed: () => setState(() => _obscure = !_obscure),
),
),
),
const SizedBox(height: 24),
ListenableBuilder(
listenable: _fields,
builder: (context, child) => FilledButton(
onPressed: _canSubmit ? _submit : null,
child: const Text('Sign up'),
),
),
],
),
),
);
}
}Here are the decisions worth noticing. We didn't write setState for the button: Listenable.merge combines the three controllers into one listenable and rebuilds only the button. Even if letters or spaces are pasted into the phone field, the formatter removes them, and LengthLimitingTextInputFormatter guarantees the 10-digit rule. Focus moves explicitly to the next field with requestFocus in onSubmitted, and on the last field the "done" key triggers sign-up directly. autofocus: true focuses the first field and opens the keyboard as soon as the screen appears. textCapitalization: TextCapitalization.words tells the keyboard to start each word with a capital letter; that's only a keyboard hint, the user can still type lowercase, so if you need a strict rule you have to write a formatter. Using SingleChildScrollView for the body keeps the fields from overflowing when the keyboard opens; details in the ScrollView post. For the result message we used a SnackBar.
TextField or TextFormField?
TextFormField is TextField wrapped to work inside a Form: it adds validator, onSaved and autovalidateMode, and takes part in the Form's single validate() call. Everything covered in this post (controller, decoration, keyboardType, inputFormatters, focusNode, obscureText) applies there unchanged; only onSubmitted is called onFieldSubmitted.
Quick rule: if several fields are validated and submitted together, use Form with TextFormField; for a search box, a chat input or a simple screen like the one above, use TextField. The full validation flow, including the difference between validate() and save(), is in the Form post. For non-text inputs (Switch, Checkbox, Slider) see the input widgets post.
Common Mistakes
1. Creating the controller inside build
Symptom: text disappears as you type, or the cursor jumps to the start. A TextEditingController() line inside build creates a new, empty controller on every rebuild. The controller must be a State field and must be closed in dispose(). If you forget dispose, the controller's listeners stay in memory even after the screen is gone.
2. Changing controller.text inside onChanged
Writing controller.text = ... in onChanged to uppercase or format the text makes the cursor jump, because assigning text resets the selection. The right place to transform input is inputFormatters; if the built-in formatters aren't enough, write your own by extending TextInputFormatter.
3. Assuming keyboardType restricts input
Letters can land in a field that opens a numeric keyboard via paste. If you use TextInputType.number, add FilteringTextInputFormatter.digitsOnly as well, and check on the server too.
4. obscureText with a multi-line field
With obscureText: true and a maxLines other than 1 you get this assertion error:
Obscured fields cannot be multiline.A password field is always single-line; just leave maxLines out.
5. Putting a bare TextField inside a Row
Symptom: An InputDecorator, which is typically created by a TextField, cannot have an unbounded width. A Row offers its children unlimited width, but a TextField has to know its width. Wrap the field in Expanded or in a SizedBox with a fixed width; how Expanded works is covered in the Expanded post.
Frequently Asked Questions
Why doesn't the keyboard close when I tap outside the field?
To follow platform conventions, Flutter doesn't give up focus on touches outside the field on mobile, while a mouse click on desktop does. If you want it to close on mobile too, give the TextField onTapOutside: (_) => FocusScope.of(context).unfocus(), or put a tap detector on the page's empty area and make the same call.
Why doesn't onChanged fire when I change the text from code?
Because onChanged only reports changes the user makes; it isn't called for changes made through the controller. If you want to react to both user and code changes, add a listener to the controller with addListener, or listen to it with ListenableBuilder.
The field at the bottom ends up behind the keyboard. What should I do?
By default Scaffold shrinks the body when the keyboard opens (resizeToAvoidBottomInset: true). If the body can't scroll, the content overflows and the lower fields become unreachable. Put the form inside a SingleChildScrollView or a ListView; when the focused field is inside a scrollable area, Flutter scrolls it into view.
Related Posts
Flutter: Form Widget and Input Validation
Flutter Form and TextFormField validation: validate() vs save(), autovalidateMode, focus handling, controller disposal and a complete login form.
Flutter: Light and Dark Themes with ThemeData
Building a Flutter theme with ThemeData: ColorScheme.fromSeed, light and dark themes, ThemeMode, reading with Theme.of, TextTheme, component themes, extensions.
Flutter: Layering Widgets with Stack and Positioned
Flutter Stack and Positioned guide: how a Stack sizes itself, fit, alignment, clipBehavior, Positioned.fill, PositionedDirectional, badge and overlay recipes.