Forms, Validation & User Input

Checkboxes, Switches & Multi-Select Patterns

16 min Lesson 9 of 12

Checkboxes, Switches & Multi-Select Patterns

Flutter provides Checkbox and Switch widgets for capturing boolean input, but by default they are not connected to the Form / FormField validation pipeline. To make them first-class form citizens — so that calling _formKey.currentState!.validate() includes their state — you must wrap them inside a custom FormField<bool>. This lesson covers how to do exactly that, and then extends the pattern to a multi-select checklist backed by a FormField<List<String>>.

Why Plain Checkbox and Switch Are Not Enough

A bare Checkbox widget stores no state itself; you must manage it with setState. That is fine for simple UI toggles, but it means the value sits outside the form's validation scope. If a user must accept terms before submitting, you need validation to enforce that rule uniformly with all other fields. Wrapping in FormField achieves this cleanly.

Note: FormField<T> is the base class behind TextFormField. It accepts an initialValue, a validator callback, an onSaved callback, and a builder that receives a FormFieldState<T> — giving you full access to the current value, error text, and save/validate hooks.

Building a CheckboxFormField

The key idea is to pass a builder to FormField<bool>. The builder receives FormFieldState<bool> field, which you can call field.didChange(newValue) on to update the form's internal state and trigger validation.

Custom CheckboxFormField Example

class CheckboxFormField extends FormField<bool> {
  CheckboxFormField({
    super.key,
    required String label,
    super.initialValue = false,
    super.validator,
    super.onSaved,
    super.autovalidateMode,
  }) : super(
          builder: (FormFieldState<bool> field) {
            return Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Checkbox(
                      value: field.value ?? false,
                      onChanged: (bool? checked) {
                        field.didChange(checked);
                      },
                    ),
                    Flexible(child: Text(label)),
                  ],
                ),
                if (field.hasError)
                  Padding(
                    padding: const EdgeInsets.only(left: 12),
                    child: Text(
                      field.errorText!,
                      style: const TextStyle(
                        color: Colors.red,
                        fontSize: 12,
                      ),
                    ),
                  ),
              ],
            );
          },
        );
}

// Usage inside a Form:
CheckboxFormField(
  label: 'I agree to the Terms and Conditions',
  validator: (value) {
    if (value != true) return 'You must accept the terms to continue.';
    return null;
  },
  onSaved: (value) => _agreedToTerms = value ?? false,
)
Tip: Always call field.didChange(newValue) inside the widget's callback — never call setState() directly. didChange updates the FormFieldState's internal value, marks the field dirty, and (when AutovalidateMode is active) triggers immediate revalidation.

Building a SwitchFormField

The pattern for Switch is identical — only the inner widget changes. You can even make a reusable generic boolean FormField that accepts a builder parameter to swap between Checkbox and Switch rendering.

Inline SwitchFormField Inside a Form

FormField<bool>(
  initialValue: false,
  validator: (value) {
    if (value != true) return 'Please enable notifications to proceed.';
    return null;
  },
  onSaved: (value) => _notificationsEnabled = value ?? false,
  builder: (FormFieldState<bool> field) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SwitchListTile(
          title: const Text('Enable push notifications'),
          subtitle: const Text('Required for order updates'),
          value: field.value ?? false,
          onChanged: field.didChange,
        ),
        if (field.hasError)
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Text(
              field.errorText!,
              style: const TextStyle(color: Colors.red, fontSize: 12),
            ),
          ),
      ],
    );
  },
)

Multi-Select Checklist Pattern

A multi-select checklist lets the user pick several options from a fixed set. Instead of FormField<bool>, you use FormField<List<String>>. The builder renders a column of CheckboxListTile widgets, and each tap calls field.didChange(updatedList) with a new copy of the list.

MultiSelectFormField for a List of Strings

class MultiSelectFormField extends FormField<List<String>> {
  MultiSelectFormField({
    super.key,
    required List<String> options,
    String? label,
    super.initialValue,
    super.validator,
    super.onSaved,
  }) : super(
          builder: (FormFieldState<List<String>> field) {
            final selected = field.value ?? <String>[];
            return Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                if (label != null)
                  Padding(
                    padding: const EdgeInsets.only(bottom: 4),
                    child: Text(
                      label,
                      style: const TextStyle(fontWeight: FontWeight.w600),
                    ),
                  ),
                ...options.map((option) {
                  return CheckboxListTile(
                    title: Text(option),
                    value: selected.contains(option),
                    onChanged: (bool? checked) {
                      final next = List<String>.from(selected);
                      if (checked == true) {
                        next.add(option);
                      } else {
                        next.remove(option);
                      }
                      field.didChange(next);
                    },
                    controlAffinity: ListTileControlAffinity.leading,
                    dense: true,
                  );
                }),
                if (field.hasError)
                  Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 16),
                    child: Text(
                      field.errorText!,
                      style: const TextStyle(color: Colors.red, fontSize: 12),
                    ),
                  ),
              ],
            );
          },
        );
}

// Usage:
MultiSelectFormField(
  label: 'Select your interests (at least 2)',
  options: const ['Flutter', 'Dart', 'Firebase', 'REST APIs', 'GraphQL'],
  initialValue: const [],
  validator: (value) {
    if (value == null || value.length < 2) {
      return 'Please select at least 2 interests.';
    }
    return null;
  },
  onSaved: (value) => _selectedInterests = value ?? [],
)
Warning: Always pass a new list copy to field.didChange() — never mutate the existing list in place. The FormFieldState stores a reference; mutating it directly bypasses the dirty-checking mechanism and the field will not revalidate or rebuild correctly. Use List<String>.from(selected) or the spread operator to create a fresh copy.

Integrating with Form Validation and Save

Because all three patterns (CheckboxFormField, SwitchFormField, MultiSelectFormField) extend FormField, they integrate seamlessly with the standard Form workflow:

  • _formKey.currentState!.validate() — calls every field's validator, including boolean and list fields
  • _formKey.currentState!.save() — calls every field's onSaved, populating your state variables
  • _formKey.currentState!.reset() — resets all fields to their initialValue

Summary

Wrapping Checkbox and Switch in a FormField<bool> gives them full participation in form validation and saving, removing the need for parallel setState management. Extending this to FormField<List<String>> covers the multi-select checklist case. The critical rule is to always call field.didChange() with a new value/copy rather than mutating state directly, so that Flutter's form machinery tracks changes correctly.