Forms, Validation & User Input

Building Custom FormField Widgets

16 min Lesson 11 of 12

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. an int for a star count).
  • An error message (String?) produced by the validator callback.
  • 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>.

Key insight: You never call 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.

Tip: If you need to read or programmatically set the value outside the Form, keep a reference to the field's state with a GlobalKey<FormFieldState<int>>, just like you would with a TextEditingController.
Warning: Never mutate the value with raw 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, and initialValue in the constructor and forward them to super.
  • In the builder, render your UI using field.value and call field.didChange() on user interaction.
  • Display field.errorText when field.hasError is true.
  • Let the parent Form drive validation and saving — never call those on the field directly.