Forms, Validation & User Input

TextFormField: Controller, Decoration & Initial Values

15 min Lesson 2 of 12

TextFormField: Controller, Decoration & Initial Values

A TextFormField is the form-aware counterpart of TextField. It integrates seamlessly with a Form widget to provide built-in validation, saving, and resetting. In this lesson you will learn the three most important customisation points: wiring up a TextEditingController, styling the field via InputDecoration, and setting a pre-filled initialValue.

Why TextFormField Over TextField?

TextField is a raw, lower-level input widget. TextFormField wraps it and adds three critical capabilities that are essential in any real form:

  • validator — a callback that returns an error string or null, invoked by FormState.validate()
  • onSaved — called when FormState.save() is invoked, letting you extract the final value
  • initialValue — pre-fills the field with a string without requiring a controller
Note: You should not use both controller and initialValue on the same TextFormField at the same time — Flutter will throw an assertion error at runtime.

Wiring a TextEditingController

A TextEditingController gives you programmatic, two-way access to the field's text. You attach it via the controller parameter and read its value at any point via controller.text. Always create the controller inside initState() and dispose of it in dispose() to avoid memory leaks.

Basic Controller Setup

class ProfileForm extends StatefulWidget {
  const ProfileForm({super.key});

  @override
  State<ProfileForm> createState() => _ProfileFormState();
}

class _ProfileFormState extends State<ProfileForm> {
  final _formKey = GlobalKey<FormState>();

  // Declare controllers as late final — created in initState
  late final TextEditingController _nameController;
  late final TextEditingController _emailController;

  @override
  void initState() {
    super.initState();
    _nameController  = TextEditingController();
    _emailController = TextEditingController(text: 'user@example.com');
  }

  @override
  void dispose() {
    _nameController.dispose();
    _emailController.dispose();
    super.dispose();
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      // Read the field values directly from the controllers
      final name  = _nameController.text.trim();
      final email = _emailController.text.trim();
      debugPrint('Name: $name, Email: $email');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(controller: _nameController),
          TextFormField(controller: _emailController),
          ElevatedButton(onPressed: _submit, child: const Text('Submit')),
        ],
      ),
    );
  }
}

Setting an Initial Value Without a Controller

When you do not need programmatic control over the field during its lifetime, use initialValue instead. The field is pre-filled and you collect the value via onSaved when the form is submitted.

Using initialValue and onSaved

class EditBioForm extends StatefulWidget {
  final String existingBio;
  const EditBioForm({super.key, required this.existingBio});

  @override
  State<EditBioForm> createState() => _EditBioFormState();
}

class _EditBioFormState extends State<EditBioForm> {
  final _formKey = GlobalKey<FormState>();
  String? _savedBio;

  void _save() {
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save(); // triggers every onSaved callback
      debugPrint('Saved bio: $_savedBio');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            // Pre-fill from the widget property — no controller needed
            initialValue: widget.existingBio,
            maxLines: 4,
            onSaved: (value) => _savedBio = value,
            validator: (value) {
              if (value == null || value.trim().isEmpty) {
                return 'Bio cannot be empty';
              }
              return null;
            },
          ),
          ElevatedButton(onPressed: _save, child: const Text('Save')),
        ],
      ),
    );
  }
}

Styling with InputDecoration

InputDecoration controls every visual aspect of the field: the label, hint text, helper text, prefix/suffix icons, borders, and error display. It accepts dozens of optional parameters; the most commonly used are listed below.

  • labelText — floating label that rises above the field when focused
  • hintText — placeholder text shown when the field is empty
  • helperText — persistent instructional text below the field
  • prefixIcon / suffixIcon — icon widgets inside the field boundary
  • border / focusedBorder / errorBorder — custom InputBorder shapes
  • filled / fillColor — background colour fill
  • prefixText / suffixText — inline text appended to the value

Rich InputDecoration Example

TextFormField(
  controller: _priceController,
  keyboardType: const TextInputType.numberWithOptions(decimal: true),
  decoration: InputDecoration(
    labelText: 'Price',
    hintText: 'Enter product price',
    helperText: 'USD only',
    prefixIcon: const Icon(Icons.attach_money),
    suffixText: 'USD',
    filled: true,
    fillColor: Colors.grey.shade100,
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    focusedBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(color: Colors.blue, width: 2),
    ),
    errorBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(color: Colors.red, width: 2),
    ),
  ),
  validator: (value) {
    if (value == null || value.isEmpty) return 'Price is required';
    final price = double.tryParse(value);
    if (price == null || price <= 0) return 'Enter a valid positive number';
    return null;
  },
)

Reading the Field Value on Submit

There are two patterns for reading the value when the user submits the form:

  • Controller pattern — call controller.text directly inside the submit handler after calling validate().
  • onSaved pattern — call formKey.currentState!.save() which fires each field's onSaved callback; store the result in a nullable variable declared in state.
Tip: The controller pattern is better when you need to react to every keystroke (e.g., live character counts or search-as-you-type). The onSaved pattern keeps the widget tree cleaner when you only need the value at the moment of submission.
Warning: Never forget to call dispose() on every TextEditingController you create. Forgetting this is one of the most common memory-leak sources in Flutter apps.

Summary

Use TextFormField inside a Form widget whenever you need validation and save integration. Attach a TextEditingController when you need programmatic access to the text at any time, or use initialValue together with onSaved for a lighter approach. Customise the field's appearance with InputDecoration, which supports labels, hints, icons, borders, and fill colours. Always dispose of controllers in dispose() to prevent memory leaks.