Skip to content

Flutter Forms — Input Fields, Validation, and Submission

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Flutter Forms. We cover key concepts, practical examples, and best practices to help you master this topic.

Flutter forms handle user input with TextFormField widgets, Form validation, and submission callbacks, enabling structured data collection with built-in validation and error handling.

What Will You Learn

  • Building forms with Form and TextFormField
  • Input validation with validators
  • Form submission and error handling
  • Input formatting and masks
  • Dropdown, date picker, and switch inputs
  • Custom form field creation
  • Managing form state

Why It Matters

Forms are the primary way users enter data in mobile apps. Flutter's Form widget provides a structured approach with built-in validation, focus management, and keyboard handling. Proper form implementation affects user experience, data quality, and Accessibility. Understanding form validation patterns, input formatting, and error display ensures a professional user experience.

Real-World Use

The DodaTech Flutter app uses forms for user registration, profile editing, and checkout. Registration has email validation (format check + uniqueness), password strength indicator, and terms acceptance. The profile form uses a date picker for birth date, dropdown for country selection, and formatted phone input. All forms use GlobalKey<FormState> for validation and submission control.

Learning Path

flowchart LR
  A[Flutter Navigation] --> B[Flutter Forms\nYou are here]
  B --> C[Flutter Networking]
  style B fill:#f90,color:#fff

Basic Form Setup

The Form widget wraps form fields and manages validation and submission:

import 'package:flutter/material.dart';

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

  @override
  State<RegistrationForm> createState() => _RegistrationFormState();
}

class _RegistrationFormState extends State<RegistrationForm> {
  final _formKey = GlobalKey<FormState>();
  final _nameController = TextEditingController();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

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

  void _submitForm() {
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Registration successful!')),
      );
      // Process form data
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Register')),
      body: Padding(
        padding: EdgeInsets.all(24),
        child: Form(
          key: _formKey,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              TextFormField(
                controller: _nameController,
                decoration: InputDecoration(
                  labelText: 'Full Name',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.person),
                ),
                textInputAction: TextInputAction.next,
                validator: (value) {
                  if (value == null || value.trim().isEmpty) {
                    return 'Please enter your name';
                  }
                  return null;
                },
              ),
              SizedBox(height: 16),
              TextFormField(
                controller: _emailController,
                decoration: InputDecoration(
                  labelText: 'Email',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.email),
                ),
                keyboardType: TextInputType.emailAddress,
                validator: (value) {
                  if (value == null || value.isEmpty) return 'Email is required';
                  if (!value.contains('@') || !value.contains('.')) {
                    return 'Enter a valid email';
                  }
                  return null;
                },
              ),
              SizedBox(height: 16),
              TextFormField(
                controller: _passwordController,
                decoration: InputDecoration(
                  labelText: 'Password',
                  border: OutlineInputBorder(),
                  prefixIcon: Icon(Icons.lock),
                ),
                obscureText: true,
                validator: (value) {
                  if (value == null || value.length < 8) {
                    return 'Password must be at least 8 characters';
                  }
                  return null;
                },
              ),
              SizedBox(height: 24),
              ElevatedButton(
                onPressed: _submitForm,
                child: Text('Register'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

GlobalKey<FormState> identifies the form. validate() runs all field validators. The validator function returns null for valid or a String error message for invalid. Each TextFormField can have its own validator.

Input Validation Techniques

Dart provides multiple validation approaches:

class ValidationExamples {
  // Basic required field
  static String? required(String? value, String fieldName) {
    if (value == null || value.trim().isEmpty) {
      return '$fieldName is required';
    }
    return null;
  }

  // Email format
  static String? email(String? value) {
    if (value == null || value.isEmpty) return null; // Optional field
    final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    if (!emailRegex.hasMatch(value)) {
      return 'Enter a valid email address';
    }
    return null;
  }

  // Min length
  static String? minLength(String? value, int min, String fieldName) {
    if (value != null && value.length < min) {
      return '$fieldName must be at least $min characters';
    }
    return null;
  }

  // Numeric range
  static String? range(String? value, num min, num max, String fieldName) {
    if (value == null || value.isEmpty) return null;
    final numVal = num.tryParse(value);
    if (numVal == null) return 'Enter a valid number';
    if (numVal < min || numVal > max) {
      return '$fieldName must be between $min and $max';
    }
    return null;
  }

  // Must match another field
  static String? matches(String? value, String compareValue, String fieldName) {
    if (value != compareValue) {
      return '$fieldName does not match';
    }
    return null;
  }
}

Extract validation logic into reusable functions or a validation mixin. This keeps widgets clean and enables Unit Testing of validation rules.

Input Formatting

Format text input as the user types:

import 'package:flutter/services.dart';

class FormattedInputs extends StatelessWidget {
  const FormattedInputs({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Phone number formatting
        TextFormField(
          decoration: InputDecoration(
            labelText: 'Phone',
            border: OutlineInputBorder(),
            prefixIcon: Icon(Icons.phone),
          ),
          keyboardType: TextInputType.phone,
          inputFormatters: [
            FilteringTextInputFormatter.digitsOnly,
            LengthLimitingTextInputFormatter(10),
            _PhoneNumberFormatter(),
          ],
        ),
        SizedBox(height: 16),
        // Uppercase input
        TextFormField(
          decoration: InputDecoration(
            labelText: 'Coupon Code',
            border: OutlineInputBorder(),
          ),
          textCapitalization: TextCapitalization.characters,
          inputFormatters: [
            UpperCaseTextFormatter(),
          ],
        ),
        SizedBox(height: 16),
        // Numeric input with decimal
        TextFormField(
          decoration: InputDecoration(
            labelText: 'Price',
            border: OutlineInputBorder(),
            prefixText: '\$ ',
          ),
          keyboardType: TextInputType.numberWithOptions(decimal: true),
          inputFormatters: [
            FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
          ],
        ),
      ],
    );
  }
}

class _PhoneNumberFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final text = newValue.text.replaceAll(RegExp(r'\D'), '');
    var formatted = '';
    for (var i = 0; i < text.length; i++) {
      if (i == 3 || i == 6) formatted += '-';
      formatted += text[i];
    }
    return newValue.copyWith(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }
}

class UpperCaseTextFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    return newValue.copyWith(text: newValue.text.toUpperCase());
  }
}

TextInputFormatter intercepts text changes and can modify the input. This is useful for phone numbers, credit cards, uppercase codes, and numeric formatting.

Use DropdownButtonFormField for selection from a list:

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

  @override
  State<DropdownExample> createState() => _DropdownExampleState();
}

class _DropdownExampleState extends State<DropdownExample> {
  String? _selectedCountry;
  DateTime? _selectedDate;
  bool _agreeToTerms = false;

  final _countries = [
    'United States',
    'Canada',
    'United Kingdom',
    'Germany',
    'France',
    'Japan',
    'Australia',
  ];

  @override
  Widget build(BuildContext context) {
    return Form(
      child: Padding(
        padding: EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // Dropdown
            DropdownButtonFormField<String>(
              value: _selectedCountry,
              decoration: InputDecoration(
                labelText: 'Country',
                border: OutlineInputBorder(),
                prefixIcon: Icon(Icons.map),
              ),
              items: _countries.map((country) {
                return DropdownMenuItem(
                  value: country,
                  child: Text(country),
                );
              }).toList(),
              onChanged: (value) {
                setState(() => _selectedCountry = value);
              },
              validator: (value) {
                if (value == null) return 'Please select a country';
                return null;
              },
            ),
            SizedBox(height: 16),
            // Date picker
            TextFormField(
              readOnly: true,
              decoration: InputDecoration(
                labelText: 'Birth Date',
                border: OutlineInputBorder(),
                prefixIcon: Icon(Icons.calendar_today),
                suffixIcon: Icon(Icons.arrow_drop_down),
              ),
              controller: TextEditingController(
                text: _selectedDate != null
                    ? '${_selectedDate!.year}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.day.toString().padLeft(2, '0')}'
                    : '',
              ),
              onTap: () async {
                final date = await showDatePicker(
                  context: context,
                  initialDate: DateTime.now(),
                  firstDate: DateTime(1900),
                  lastDate: DateTime.now(),
                );
                if (date != null) {
                  setState(() => _selectedDate = date);
                }
              },
            ),
            SizedBox(height: 16),
            // Switch
            SwitchListTile(
              title: Text('Agree to Terms'),
              value: _agreeToTerms,
              onChanged: (value) {
                setState(() => _agreeToTerms = value);
              },
            ),
            SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {},
              child: Text('Submit'),
            ),
          ],
        ),
      ),
    );
  }
}

DropdownButtonFormField integrates with Form validation. The date picker opens in a bottom sheet or dialog. SwitchListTile provides a labeled toggle.

Autocomplete

Flutter provides Autocomplete for suggestions:

class AutocompleteExample extends StatelessWidget {
  const AutocompleteExample({super.key});

  static const _suggestions = [
    'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry',
    'Fig', 'Grape', 'Honeydew', 'Kiwi', 'Lemon',
    'Mango', 'Nectarine', 'Orange', 'Papaya', 'Quince',
  ];

  @override
  Widget build(BuildContext context) {
    return Autocomplete<String>(
      optionsBuilder: (textEditingValue) {
        if (textEditingValue.text.isEmpty) return [];
        return _suggestions.where((suggestion) {
          return suggestion.toLowerCase().contains(
            textEditingValue.text.toLowerCase(),
          );
        });
      },
      onSelected: (selection) {
        print('Selected: $selection');
      },
      fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
        return TextFormField(
          controller: controller,
          focusNode: focusNode,
          decoration: InputDecoration(
            labelText: 'Search fruit',
            border: OutlineInputBorder(),
            prefixIcon: Icon(Icons.search),
          ),
        );
      },
    );
  }
}

Autocomplete shows suggestions as the user types. The optionsBuilder returns matching items. Use fieldViewBuilder for custom styling.

Focus Management

Manage focus between fields for better keyboard UX:

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

  @override
  State<FocusExample> createState() => _FocusExampleState();
}

class _FocusExampleState extends State<FocusExample> {
  final _field1Focus = FocusNode();
  final _field2Focus = FocusNode();
  final _field3Focus = FocusNode();

  @override
  void dispose() {
    _field1Focus.dispose();
    _field2Focus.dispose();
    _field3Focus.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      child: Column(
        children: [
          TextFormField(
            focusNode: _field1Focus,
            decoration: InputDecoration(labelText: 'Field 1'),
            textInputAction: TextInputAction.next,
            onFieldSubmitted: (_) => _field2Focus.requestFocus(),
          ),
          TextFormField(
            focusNode: _field2Focus,
            decoration: InputDecoration(labelText: 'Field 2'),
            textInputAction: TextInputAction.next,
            onFieldSubmitted: (_) => _field3Focus.requestFocus(),
          ),
          TextFormField(
            focusNode: _field3Focus,
            decoration: InputDecoration(labelText: 'Field 3'),
            textInputAction: TextInputAction.done,
          ),
        ],
      ),
    );
  }
}

Set textInputAction: TextInputAction.next to show a "Next" button on the keyboard. Use onFieldSubmitted to move focus to the next field. The last field uses TextInputAction.done to show a "Done" button.

Common Mistakes

  1. Not disposing TextEditingController: Controllers must be disposed to prevent memory leaks. Always dispose in State.dispose().

  2. Using GlobalKey for each field: Use one Form key for the entire form. Individual fields use their own validators and controllers.

  3. Not preventing form submission while processing: Disable the submit button or show a loading indicator while the form is being submitted to prevent duplicate submissions.

  4. Ignoring keyboard type for input fields: Set the appropriate keyboardType (email, phone, number, url) to show the correct keyboard for each field type.

  5. Not handling autovalidate mode: Use autovalidateMode: AutovalidateMode.onUserInteraction to show validation errors after the user interacts with a field, not before.

Practice Questions

  1. How does Form.validate() work with individual field validators?
  2. What is the purpose of TextInputFormatter?
  3. How does FocusNode help manage keyboard navigation between fields?
  4. When would you use AutovalidateMode?
  5. Challenge: Build a checkout form with fields for name, email, shipping address, credit card number (formatted with spaces every 4 digits), expiration date (MM/YY), and CVV. Implement validation for all fields and format the credit card number as the user types.

Mini Project

Build a complex registration form:

  • Personal details section: name, email, phone (formatted), birth date (date picker)
  • Address section: street, city, state (dropdown), zip code
  • Account section: username, password (with strength indicator), confirm password
  • Preferences section: newsletter toggle, theme selection (radio buttons)
  • All fields validated with reusable validators
  • Form data collected and printed as JSON on submission
  • Keyboard navigation between fields

FAQ

How do I reset a form to its initial state?

Call _formKey.currentState?.reset(). This clears all fields and validation states. Also reset controllers with controller.clear().

What is the difference between validator and autovalidate?

Validator runs when formKey.currentState.validate() is called. Autovalidate gives a mode that validates automatically (on user interaction, always, or never).

Can I have multiple forms on the same screen?

Yes. Use separate GlobalKey instances for each Form. This is useful for multi-section settings screens.

How do I handle form state with Provider or BLoC?

Keep the form State in the widget. Call a provider method with the validated form data on submit. Let the widget manage focus and validation.

How do I create a custom form field?

Extend FormField<T> and override builder. The builder receives FormFieldState<T> for accessing the current value, error, and validation state.

What is Next

Now that you understand forms, learn about networking in Flutter. Proceed to Flutter Networking for HTTP requests, REST APIs, and JSON Serialization. Then explore Flutter Theming for app design and branding.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro