A TextEditingController holds a field's current value (the text, the selection and the IME composing range) and lets you read, set and listen to it. Created in build, it is replaced by a new empty one on every rebuild, so the typed text vanishes.
Read and write: controller.text reads the value; assigning controller.text replaces it and resets the selection; controller.value sets text and caret together; clear() empties the field
Listen: it is a ValueNotifier<TextEditingValue>, so addListener and ValueListenableBuilder see every change
Create once: as a field of the State or in initState, never in build and never in a StatelessWidget
Dispose: call dispose() in State.dispose; dispose only controllers you created, not one the parent passed in
Common mistake: passing both controller and initialValue to TextFormField fails an assertion; put the starting text in the controller
FlutterJunior
Forms, Input & Focus
TextField, TextEditingController, Form validation, FocusNode, input formatters, keyboard insets
Questions closed
0 / 21
- 0Review
- 4Asked often
Questions
TextFormField is a TextField wrapped in a FormField<String>: it registers with the enclosing Form and adds validator, onSaved, initialValue and autovalidateMode, showing the validator's message as the field's error.
**TextField**: the plain input; you show an error yourself through InputDecoration.errorText
**TextFormField**: meant for a Form: validate(), save() and reset() reach it
Same input options: controller, focusNode, keyboardType, inputFormatters and decoration are passed through to the inner TextField
Rule of thumb: a search box or a chat input is a TextField; a field validated together with others is a TextFormField
Form groups TextFormFields, a GlobalKey<FormState> gives you access to its state, and each field's validator returns an error message or null. On submit you call _formKey.currentState!.validate(): it runs every validator, shows the messages and returns true only if all passed.
Validator contract: synchronous String? Function(String?): null means valid, a string is the text shown under the field
Where the key lives: in the State, created once; a key created in build gives you a new FormState on every rebuild
When errors appear: by default only after validate(); autovalidateMode changes that
After validation: read the values from controllers, or call save() to run each field's onSaved
Not a security check: client validation is for the user's convenience; the server validates again
onChanged fires when the user edits the text, onSubmitted when they press the keyboard's action key, and a controller listener fires on every change of the value, including a caret move and text you set in code.
**onChanged**: runs on typing, paste and autofill; it does not run when you assign controller.text in code
**onSubmitted: runs on Done, Next, Search or Enter in a single-line field
Controller listener**: reacts to text, selection and composing alike, so a tap that only moves the caret triggers it
Common mistake: a search wired to addListener fires a request when the user just moves the caret; compare with the last text first, or use onChanged
All four are drawn by InputDecorator inside the field's own box. The label floats, the hint shows while the field is empty and focused (or has no label), and helper and error share the line under the field; if there was no helper line, errorText adds one, the field grows and everything below moves.
**labelText: sits inside an empty field and floats up to the border when the field is focused or filled
hintText**: shown while the value is empty; when a labelText is set, only once the field has focus and the label has floated up
**helperText: the permanent line under the field
errorText**: replaces the helper line and turns the border and label to the error colour; TextFormField fills it from the validator
Keep the layout still: reserve the line with helperText: ' ' and cap long messages with errorMaxLines
Shared styling belongs in ThemeData.inputDecorationTheme; the per-field InputDecoration keeps only the texts.
They configure the on-screen keyboard, not the value: keyboardType picks the layout, textInputAction sets the action key, textCapitalization sets the shift state. None of them stops the user from entering anything else.
**keyboardType**: emailAddress adds @, phone and number show a digit pad, multiline keeps Enter for new lines (with maxLines: null)
**textInputAction**: labels the action key (next, done, search, send); by default next moves focus to the next field and done closes the keyboard
**textCapitalization**: words, sentences or characters; only the keyboard's shift changes, the text does not
Common mistake: trusting the keyboard: a digit pad still accepts pasted letters and a hardware keyboard ignores it, so enforce the rule with inputFormatters and the validator
With resizeToAvoidBottomInset: true (the default) the Scaffold shrinks its body by the keyboard's height. It cannot make a fixed Column scroll, so whatever no longer fits overflows or ends up hidden; the body itself has to be scrollable.
The number: MediaQuery.viewInsetsOf(context).bottom is the keyboard height when read above the Scaffold or in bottomNavigationBar; inside body the Scaffold has already removed it (0) while resizeToAvoidBottomInset is true. SafeArea reads padding, whose bottom part drops to zero while the keyboard is open
Make the body scrollable: SingleChildScrollView with a Column, or a ListView; the focused field is then scrolled into view for you
Bars at the bottom: bottomNavigationBar is not lifted above the keyboard; pad it with the view inset or move the button into the scrollable body
Common mistake: resizeToAvoidBottomInset: false removes the overflow stripes and leaves the button under the keyboard for good
The Form stores no values. Each field keeps its own value in its FormFieldState, and save() only calls every field's onSaved; if you wrote no onSaved, nothing is written anywhere.
**save()**: calls onSaved(value) on every field and returns nothing; your callback is what writes into the model
**reset()**: puts every field back to its initialValue and clears the errors
Two ways to read values: controllers (_email.text after validate() passes) or onSaved plus save(); pick one per form, not both
**validateGranularly()**: validates like validate() but returns the set of failing FormFieldStates, handy to scroll to the first one
Common mistake: _formKey.currentState is null until the Form has built once, so calling it from initState throws
Nested forms: a field registers with the nearest Form, so fields of an inner Form are invisible to the outer validate() and save()
initialValue is read only once, when the field's FormFieldState is created. On the first build _profile is still null, so the field starts empty, and the rebuild after loading hands the name to a field that ignores it.
Why it is ignored: TextFormField copies initialValue into its internal controller in initState; didUpdateWidget reacts only to a new controller, so later values never reach the screen (only reset() reads initialValue again)
Fix one: build the field when the data is there: show a loader until _profile is set, then build the form once with real initial values; the user never types into a field that is about to be replaced
Fix two: own a controller: on load set _name.text = p.name (and dispose() it); use it when the form must be visible while loading, and do not overwrite text the user has already typed
**A changing Key is a blunt fix**: ValueKey(_profile) does recreate the field, but each new key throws away its text, caret and error
Catch it in a test: back the repo with a Completer, pumpWidget the form, complete it, pump(), then expect(find.text('Ada'), findsOneWidget)
That is AutovalidateMode.always: it runs every validator on every build, the first one included, so an untouched form is already full of errors. Use onUserInteraction or onUnfocus instead, plus an explicit validate() on submit.
**disabled**: the default; validators run only when you call validate()
**onUserInteraction**: set on a field, it stays quiet until the user changes that field, then re-validates on every keystroke; set on the Form, the first edit in any field validates all of them
**onUnfocus: validates when focus leaves the field; better for rules you cannot satisfy mid-typing, like a full email or matching passwords
always: makes sense only for a form that opens pre-filled, like an edit screen
Set it per field**: the Form's mode adds to each field's own, it does not replace it; keep the form disabled and put the mode on the fields when untouched ones must stay quiet
Common mistake: skipping validate() on submit: under onUserInteraction a field nobody touched was never checked
decoration.errorText is your own state, not the form's: TextFormField only overlays the validator's message when there is one, and nothing resets _serverError when the user types. So the old server message stays until you clear it yourself.
Clear it on edit: in onChanged, setState(() => _serverError = null) once the text changes
**Use forceErrorText**: since Flutter 3.24 TextFormField and FormField take forceErrorText: while it is non-null the field shows it instead of running the validator; set it from the server reply and clear it on edit
**validate() does not see it**: an error passed through decoration.errorText is invisible to validate(), so the form reports itself valid while red text is on screen
One owner per message: local format rules in validator, server verdicts in state (a verdict you want validate() to see can also be returned from the validator out of state, as in the async check pattern)
A FocusNode is a field's place in the focus tree, which lives next to the widget tree; the node with primary focus receives keyboard input. You move focus with requestFocus() or nextFocus() and close the keyboard by unfocusing.
Moving focus: _passwordNode.requestFocus() jumps to one field; FocusScope.of(context).nextFocus() walks fields in reading order, and TextInputAction.next calls it for you
Why Next skips a field: disabled fields and nodes with canRequestFocus: false are skipped; for a custom order use FocusTraversalGroup with OrderedTraversalPolicy
Dismissing the keyboard: FocusManager.instance.primaryFocus?.unfocus(); on Android and iOS a tap outside the field does nothing by default, so handle it with onTapOutside or a GestureDetector
Ownership: like controllers: create the node in State, dispose it in dispose(), never in build
Common mistake: FocusScope.of(context).requestFocus(FocusNode()) to hide the keyboard creates an undisposed node on every tap
Providing onEditingComplete replaces the default action-key handling, and that default is what calls nextFocus() for TextInputAction.next. With your callback in place, Next only saves the draft and focus stays where it was.
The defaults you replaced: without onEditingComplete, next calls nextFocus(), previous calls previousFocus(), and done, go, search and send unfocus the field
Fix one: end your callback with FocusScope.of(context).nextFocus() or _passwordNode.requestFocus()
Fix two: move _saveDraft to onSubmitted: it runs after the default handling instead of replacing it
Rule of thumb: onEditingComplete is for taking over the action key; side effects belong in onSubmitted
A formatter's formatEditUpdate(oldValue, newValue) returns a whole TextEditingValue, so it decides where the caret goes too. Setting it to text.length pins it to the end; instead count the digits left of the caret and place it after the same number of digits in the masked text.
When it runs: on every edit, before onChanged, in the order of the inputFormatters list; put FilteringTextInputFormatter.digitsOnly first so the mask only sees digits
Clamp the offset: TextSelection.collapsed(offset: …) must stay within 0..text.length, or the field throws
**Drop composing**: after rewriting the text return TextRange.empty, not the incoming range
Rule of thumb: keep raw digits in the model and mask only for display; for phone and card masks mask_text_input_formatter is a solid off-the-shelf choice
Keep the network call out of the validator. Debounce onChanged, store the server's answer in the State, and let the synchronous validator return the message that is already there.
How it fits together: onChanged restarts a Timer of about 400 ms; when it fires you call the API and store the result with setState (after checking mounted). With AutovalidateMode.onUserInteraction the field re-validates on that rebuild and shows the message.
Out-of-order replies: the answer for "ad" can arrive after the one for "ada"; tag each request and drop stale replies, or cancel the previous one
While waiting: disable submit and show a spinner in suffixIcon; "no answer yet" is not "valid"
The server has the last word: the name can be taken between your check and the submit, so handle that rejection too
Subclass FormField<T>. It owns the value and the error text and (the part a hand-made widget never does) registers itself with the enclosing Form, so validate(), save() and reset() reach it.
What you get: the constructor already takes initialValue, validator, onSaved and autovalidateMode; you supply a builder that receives a FormFieldState<T>
**Report changes with didChange**: when the user picks a date call state.didChange(picked), not your own setState, or save() writes stale data
Look like a text field: wrap the content in InputDecorator with InputDecoration(errorText: state.errorText) to get the same label, border and error styling
Common mistake: initialValue is read once; to push a new value from the parent, give the field a new Key
Autofill is a contract with the OS: fields need autofillHints, they must sit inside one AutofillGroup, and you should finish the autofill context after a successful sign-in.
Hints: AutofillHints.username and AutofillHints.password for login; newPassword on sign-up so the OS offers to generate one
Group: wrap the fields in one AutofillGroup; that tells the platform they form one credential
Finish: TextInput.finishAutofillContext() after a successful sign-in tells the OS to offer saving; AutofillGroup also commits when it is disposed, so the call matters when the login screen stays in the tree; pass shouldSave: false when the user cancels
SMS codes: AutofillHints.oneTimeCode works on iOS out of the box; on Android support varies by autofill service, so apps use the SMS Retriever API through a plugin. Six one-character boxes defeat autofill; use one field drawn as boxes
Domain link: to suggest passwords already saved for your website, the app must be linked to it: webcredentials: on iOS, assetlinks.json on Android
The TextEditingController should own the text and the caret. If you assign controller.text from every emitted state, you reset the caret and race the keyboard, so characters get lost.
The round trip field → onChanged → bloc → state → rebuild → assign is asynchronous: a keystroke that lands mid-trip is overwritten by the state built from the previous one.
**Keep the controller in the widget's State: it is the source of truth for text and caret; the bloc holds what is derived: the error, whether submit is enabled, the results
Push text in only when it did not come from the keyboard: clear on submit, prefill a draft, insert a scanned barcode
Do it in a BlocListener**: guarded with if (state.query != controller.text), so an echo of the user's own typing is ignored
Rotation is not the culprit: in Flutter it is just a resize, and every State survives it. Text that vanishes on rotation means the field's Element was unmounted and rebuilt; restoration is only for process death.
What rotation destroys
Nothing by default: the Flutter activity handles orientation itself, so State objects and their controllers stay alive
Your own tree change: a changed Key, a changed widget type, or an if (isPortrait) … else … branch that builds the field in a different place creates a new State with an empty controller; keep one tree and vary only the layout around it
What restoration is for
Process death: the OS reclaims a backgrounded app; nothing in Dart memory comes back
Wiring: MaterialApp(restorationScopeId: 'app'), then RestorationMixin with a restorationId and registerForRestoration(_email, 'email') in restoreState, using RestorableTextEditingController
Routes too: push with restorablePush/restorablePushNamed, or the page itself is never recreated
Keep it small: restoration data is a few KB of serializable state, not a cache
ListView.builder unmounts children that scroll out of its cache area, and everything they owned dies with them: the row's TextEditingController and its FormFieldState. The text is not hidden, it is gone.
Worse than lost text: a FormFieldState unregisters from the Form when it is unmounted, so an invalid field scrolled off screen lets validate() return true
What does not help: PageStorageKey restores scroll offsets, not field values; a GlobalKey cannot keep alive a row the builder never built
Move ownership up: the page's State holds a Map<String, TextEditingController> and hands each row its controller, so scrolling destroys only the widget
Keep the rows alive: AutomaticKeepAliveClientMixin in the row, or drop the lazy list for SingleChildScrollView + Column: thirty fields are not a feed, building all of them is cheap and every validator stays registered
Validate the data, not the widgets: for long or multi-step forms run the rules on the model on submit, not only through mounted fields
While an IME is composing a word, the field holds provisional text marked by composing, a range the platform owns. This formatter rewrites the text under that range but returns the incoming selection and composing unchanged, so Flutter and the keyboard stop agreeing on what is on screen.
What composing is
Three parts: a TextEditingValue is text, selection and composing: the underlined range the IME is still working on
Who uses it: Japanese, Chinese and Korean input, and predictive text on Android keyboards, where the current word stays "composing" until it is committed
What breaks: the IME keeps editing the range it remembers; if the text under it changed, the next update duplicates or drops letters. toUpperCase can also change the length (ß becomes SS), so the copied caret offset lands inside the word instead of after it
How to fix it
Do not format mid-composition: if (newValue.composing.isValid) return newValue; and format once the word is committed
Never reuse old offsets after changing the text: recompute the caret for the new text and clamp it to 0..length and return TextRange.empty for composing
Length limits: LengthLimitingTextInputFormatter with MaxLengthEnforcement.truncateAfterCompositionEnds waits for the IME to commit