Building Custom FormField Widgets
Building Custom FormField Widgets
Flutter's built-in TextFormField and DropdownButtonFormField are convenient, but real apps often need custom input widgets — a star-rating picker, a colour swatch selector, a tag chip editor — that must participate in the Form save/validate cycle. The generic FormField<T> base class lets you wrap any arbitrary widget and make it a first-class citizen of the form.
How FormField<T> Works
Every FormField holds its own FormFieldState<T>, which stores three things:
- The current value of type
T(e.g. anintfor a star count). - An error message (
String?) produced by thevalidatorcallback. - A dirty flag so the field knows whether the user has interacted with it.
When the enclosing Form calls FormState.validate(), it iterates every registered FormFieldState and invokes its validator. When the form calls FormState.save(), it invokes each field's onSaved callback. Your custom widget gets both for free simply by extending FormField<T>.
validate() or save() on individual fields — you call them on the parent FormState (retrieved via _formKey.currentState!). Flutter propagates those calls to every FormField in the subtree automatically.The FormFieldBuilder Signature
The FormField constructor's required builder parameter has this signature:
// The builder receives the live FormFieldState and returns a Widget.
// field.value — current value of type T
// field.errorText — null if valid, non-null string if invalid
// field.didChange(newValue) — call this to update the value & trigger rebuild
Widget Function(FormFieldState<T> field)
Inside the builder you render whatever UI you like, wire user-interaction events to field.didChange(), and optionally display field.errorText below the widget. That single pattern is all you need.
Example 1 — Star Rating FormField
The following widget lets a user pick 1–5 stars and plugs directly into a Form:
class StarRatingFormField extends FormField<int> {
StarRatingFormField({
super.key,
int initialValue = 0,
super.onSaved, // FormFieldSetter<int>? — called by Form.save()
super.validator, // FormFieldValidator<int>? — called by Form.validate()
super.autovalidateMode,
}) : super(
initialValue: initialValue,
builder: (FormFieldState<int> field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (index) {
final starNumber = index + 1;
return IconButton(
icon: Icon(
starNumber <= (field.value ?? 0)
? Icons.star
: Icons.star_border,
color: Colors.amber,
),
onPressed: () => field.didChange(starNumber),
);
}),
),
// Show validation error beneath the stars
if (field.hasError)
Padding(
padding: const EdgeInsets.only(left: 12, top: 4),
child: Text(
field.errorText!,
style: TextStyle(
color: Theme.of(field.context).colorScheme.error,
fontSize: 12,
),
),
),
],
);
},
);
}
Wiring the Custom Field into a Form
Usage is identical to TextFormField. Pass it a validator and an onSaved callback, then call _formKey.currentState!.validate() and save() from a submit button:
class ReviewForm extends StatefulWidget {
const ReviewForm({super.key});
@override
State<ReviewForm> createState() => _ReviewFormState();
}
class _ReviewFormState extends State<ReviewForm> {
final _formKey = GlobalKey<FormState>();
int _savedRating = 0;
void _submit() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Rating saved: $_savedRating stars')),
);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Rate this product'),
StarRatingFormField(
validator: (value) {
if (value == null || value == 0) {
return 'Please select at least 1 star.';
}
return null; // valid
},
onSaved: (value) => _savedRating = value ?? 0,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _submit,
child: const Text('Submit'),
),
],
),
);
}
}
AutovalidateMode and Reset
Pass autovalidateMode: AutovalidateMode.onUserInteraction to validate each star tap in real time. The inherited FormFieldState.reset() restores the field to initialValue when the parent form calls FormState.reset() — you get this behaviour for free without any extra code.
Form, keep a reference to the field's state with a GlobalKey<FormFieldState<int>>, just like you would with a TextEditingController.setState() inside a FormField builder — always call field.didChange(newValue). Direct setState bypasses the FormFieldState machinery and the form will not see the updated value when it calls save() or validate().Summary
Custom FormField<T> widgets are the correct Flutter-idiomatic way to integrate any arbitrary input into the Form save/validate lifecycle. The recipe is:
- Extend
FormField<T>with a type parameter that matches your data. - Accept
onSaved,validator, andinitialValuein the constructor and forward them tosuper. - In the
builder, render your UI usingfield.valueand callfield.didChange()on user interaction. - Display
field.errorTextwhenfield.hasErroris true. - Let the parent
Formdrive validation and saving — never call those on the field directly.