TextFormField: Controller, Decoration & Initial Values
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 byFormState.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
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
InputBordershapes - 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.textdirectly inside the submit handler after callingvalidate(). - onSaved pattern — call
formKey.currentState!.save()which fires each field'sonSavedcallback; store the result in a nullable variable declared in state.
onSaved pattern keeps the widget tree cleaner when you only need the value at the moment of submission.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.