skip to content

In Flutter, where should a TextFormField's TextEditingController be created and disposed, and what breaks if build() creates it?

level: middleimportance: must knowfreq 62%

answer

  1. a ChangeNotifier holding text and selection
  2. one per State, not per build
  3. dispose() before super.dispose()
  4. you own the controller you pass
  5. controller or initialValue, never both

basics

~20 s

Create a TextEditingController once per State, as a field initializer or in initState, and call its dispose() in dispose(). Created inside build(), every rebuild hands the field a fresh empty controller, losing the typed text, and the old ones are never disposed.

solid answer

~40 s

`TextEditingController` extends `ValueNotifier<TextEditingValue>`, so it is a `ChangeNotifier` holding the text, the selection and the composing range. It must outlive rebuilds, so I create it once in the `State` (a field initializer or `initState`) and call `_controller.dispose()` in `dispose()` before `super.dispose()`. If `build()` creates it, each rebuild passes a new, empty controller; `TextFormField` switches to it, so the user's text and cursor are lost, and the old controllers are never disposed. If I pass no controller, the field creates and disposes its own; if I pass one, I own it. A field takes either `controller` or `initialValue`, not both; an assertion enforces that.

code

dart · 34 lines
dart
import 'package:flutter/material.dart';

class PlateField extends StatefulWidget {
  const PlateField({super.key, this.savedPlate = ''});

  final String savedPlate;

  @override
  State<PlateField> createState() => _PlateFieldState();
}

class _PlateFieldState extends State<PlateField> {
  late final TextEditingController _plate;

  @override
  void initState() {
    super.initState();
    _plate = TextEditingController(text: widget.savedPlate);
  }

  @override
  void dispose() {
    _plate.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: _plate,
      decoration: const InputDecoration(labelText: 'Plate number'),
    );
  }
}

go deeper

for a junior

Remember the pattern: create the controller once in the State, pass it to the field, and dispose it in dispose() before calling super.dispose().

for a middle

Explain why: it is a ChangeNotifier that must outlive rebuilds, a controller made in build() is replaced on every rebuild, and whoever creates it disposes it.

for a senior

Diagnose the symptoms in a real screen: text that vanishes when the keyboard opens, leak reports for undisposed notifiers, and programmatic fills that trip autovalidation.

for a principal

Decide where text state lives in larger forms: controllers owned by widgets, or values held in a state object with controllers only at the edges.

## What the controller is A **`TextEditingController`** is the object that holds a text field's editable state. It extends **`ValueNotifier<TextEditingValue>`**, and a `TextEditingValue` bundles three things: - `text`, the current string; - `selection`, the cursor position or selected range; - `composing`, the range an input method is still composing (for example while a user builds a character with an IME). Because it is a **`ChangeNotifier`**, anything can `addListener` to it, and the field itself listens so it can repaint when code changes the text. Like every `ChangeNotifier`, it has a `dispose()` method, and using it after disposal throws in debug builds (`A TextEditingController was used after being disposed.`). ## Where it lives A widget's `build()` can run many times a second; a `State` object lives as long as the widget's place in the tree. The controller belongs to the `State`: 1. Declare it as a field, either initialised inline (`final _plate = TextEditingController();`) or as `late final` assigned in `initState()` when it needs `widget` data such as a saved plate number. 2. Pass it to `TextFormField(controller: _plate)`. 3. In `dispose()`, call `_plate.dispose()` and then `super.dispose()`. Ownership follows creation. If you pass **no** controller, `TextFormField` creates an internal one (a restorable controller) and disposes it itself. If you **do** pass one, the field only adds and removes its listener; disposing it is your job. ## What goes wrong when build() creates it Writing `TextFormField(controller: TextEditingController())` inside `build()` looks harmless until something rebuilds the widget: a `setState` elsewhere in the screen, a theme change, or a `MediaQuery` change the widget depends on, such as the keyboard opening. Then: - the field receives a **different controller**, whose text is empty (or whatever you passed to its constructor), and adopts it, so what the user typed disappears and the cursor jumps; - every previous controller is **never disposed**; `ChangeNotifier` reports its creation to Flutter's leak tracking, which, when enabled (typically in tests), flags notifiers that were never disposed; - any listener you attached to the old controller silently stops seeing changes. ## Controller or initialValue | You need | Use | |---|---| | Only the final value, at submit | `initialValue` plus `onSaved` or the validator | | To read or change the text while the user edits | a `controller` | | To move the cursor or select text from code | a `controller` and its `value` or `selection` | | Both a starting text and a controller | `TextEditingController(text: ...)`, not `initialValue` | `TextFormField` asserts `initialValue == null || controller == null`: set both and the constructor fails in debug builds. With a controller, the form field's initial value is the controller's text, and `FormState.reset()` writes the text the field started with back into the controller. ## Changing the text from code - **`controller.text = 'AB12CDE'`** replaces the whole value and **resets the selection and composing range**; the framework documentation calls it a setter mostly for tests. In production, assign `controller.value = TextEditingValue(text: ..., selection: TextSelection.collapsed(offset: ...))` so the cursor lands where you intend. - A programmatic change **does not** call the field's `onChanged` and **does not** run `inputFormatters`; both react to user edits only. - The controller's **listeners do fire**, and `TextFormField` forwards the new text to its `FormFieldState`, which marks the field as interacted with. Under `AutovalidateMode.onUserInteraction` that can make a programmatic fill show validation errors. - Listeners also fire on **selection-only** changes, such as the user moving the cursor, because the `value` changed even though the text did not. Compare `text` if you only care about content. ## Listening to the controller Because the controller is a `ValueListenable<TextEditingValue>`, two idioms cover most live-update needs: - **`ValueListenableBuilder<TextEditingValue>`** rebuilds only the widget it wraps, for example a "7 of 8 characters" hint under the plate field or a Register button that enables once the plate looks complete, without calling `setState` on the whole screen. - **`addListener`** suits side effects such as saving a draft. Pair every `addListener` with a `removeListener`, or dispose the controller, so the callback does not outlive the screen. Either way, compare the new `text` with the previous one if you only care about content, since cursor moves notify too. ## Common mistakes - Disposing the controller *after* `super.dispose()`, or forgetting it because "the garbage collector handles it". - Disposing a controller that a parent owns and passed down. - Recreating the controller in `didUpdateWidget` without disposing the old one.

  • Why does assigning controller.text move the cursor, and what should production code use instead?
    The `text` setter replaces the whole `TextEditingValue` and resets the selection and composing range, so the cursor jumps. Assign `controller.value` instead, with a `TextEditingValue` whose `selection` you choose, for example `TextSelection.collapsed(offset: newText.length)`, or use `controller.value.copyWith(...)` to keep what you do not change.
  • Does setting controller.text call the TextFormField's onChanged or its inputFormatters?
    Neither. `onChanged` and `inputFormatters` run only for edits the user makes. The controller's own listeners do fire, and `TextFormField` passes the new text to its form field, which counts it as an interaction for autovalidation.
  • When can a TextFormField skip the controller entirely?
    When you only need the value at submit time. Give it an `initialValue`, read the value in `onSaved` or the validator, and let the field create and dispose its own internal controller. Reach for a controller once you must read the text live, change it from code, or move the selection.

saying these in an interview costs you the question

  • Creating a TextEditingController in build() is fine because widgets rarely rebuild.
  • Garbage collection disposes a TextEditingController, so dispose() is optional.
  • Pass both controller and initialValue to give a controlled field its starting text.
  • TextFormField disposes the controller you pass to it.
  • Setting controller.text triggers the field's onChanged callback.