Skip to content
ExponaBLOG
FLUTTER · DAY 048 / 100

Forms and validation: Flutter best practices explained

Reliable input handling in Flutter.

Expona Admin9 min read

Forms and validation: Flutter best practices explained

Input validation is often where user experience either flows or falters—a missing email check or a silently rejected form submission can destroy trust fast. Building reliable input handling in Flutter means understanding the interplay between form state, validation rules, input constraints, and submission feedback. Let's walk through the patterns that keep forms predictable and resilient in production apps.

Form and TextFormField — Built-in validation with a GlobalKey<FormState>

Flutter's Form widget provides a stateful wrapper around multiple input fields. It doesn't validate on its own—instead, it holds a reference point (GlobalKey<FormState>) that lets you trigger validation, reset fields, and save values across the entire form at once. This is far cleaner than managing individual field states manually.

TextFormField is the input counterpart. It wraps TextField and adds a validator parameter—a callback that runs when you call form.validate(). The validator receives the current field value and returns either null (valid) or an error string to display.

Here's a practical sign-up form:

dart
class SignUpForm extends StatefulWidget {
  @override
  State<SignUpForm> createState() => _SignUpFormState();
}

class _SignUpFormState extends State<SignUpForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  
  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _submitForm() {
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save();
      // Form is valid, proceed with signup
      print('Email: ${_emailController.text}');
      print('Password: ${_passwordController.text}');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(labelText: 'Email'),
            validator: (value) {
              if (value == null || value.isEmpty) {
                return 'Email is required';
              }
              if (!value.contains('@')) {
                return 'Enter a valid email';
              }
              return null;
            },
          ),
          TextFormField(
            controller: _passwordController,
            decoration: InputDecoration(labelText: 'Password'),
            obscureText: true,
            validator: (value) {
              if (value == null || value.isEmpty) {
                return 'Password is required';
              }
              if (value.length < 8) {
                return 'Password must be at least 8 characters';
              }
              return null;
            },
          ),
          SizedBox(height: 20),
          ElevatedButton(
            onPressed: _submitForm,
            child: Text('Sign Up'),
          ),
        ],
      ),
    );
  }
}

The key pattern: _formKey.validate() triggers all validators in the form. Each TextFormField shows an error message below its input if validation fails. Only when all validators return null does the form proceed.

Validators — Reusable functions returning error messages

Inline validators work for simple cases, but production apps often repeat the same checks across multiple forms. Extract validators into reusable functions. This centralizes logic, makes testing easier, and keeps your form code readable.

Build a validators utility:

dart
class Validators {
  static String? email(String? value) {
    if (value == null || value.isEmpty) {
      return 'Email is required';
    }
    final emailRegex = RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$');
    if (!emailRegex.hasMatch(value)) {
      return 'Enter a valid email address';
    }
    return null;
  }

  static String? password(String? value) {
    if (value == null || value.isEmpty) {
      return 'Password is required';
    }
    if (value.length < 8) {
      return 'Password must be at least 8 characters';
    }
    if (!value.contains(RegExp(r'[A-Z]'))) {
      return 'Password must contain an uppercase letter';
    }
    if (!value.contains(RegExp(r'[0-9]'))) {
      return 'Password must contain a number';
    }
    return null;
  }

  static String? confirmPassword(String? value, String passwordValue) {
    if (value != passwordValue) {
      return 'Passwords do not match';
    }
    return null;
  }

  static String? phone(String? value) {
    if (value == null || value.isEmpty) {
      return 'Phone is required';
    }
    final phoneRegex = RegExp(r'^\+?[1-9]\d{1,14}$');
    if (!phoneRegex.hasMatch(value.replaceAll(' ', ''))) {
      return 'Enter a valid phone number';
    }
    return null;
  }

  static String? notEmpty(String? value, String fieldName) {
    if (value == null || value.trim().isEmpty) {
      return '$fieldName is required';
    }
    return null;
  }
}

Now your form stays lean:

dart
TextFormField(
  controller: _emailController,
  decoration: InputDecoration(labelText: 'Email'),
  validator: Validators.email,
),
TextFormField(
  controller: _passwordController,
  decoration: InputDecoration(labelText: 'Password'),
  obscureText: true,
  validator: Validators.password,
),
TextFormField(
  controller: _confirmPasswordController,
  decoration: InputDecoration(labelText: 'Confirm Password'),
  obscureText: true,
  validator: (value) {
    return Validators.confirmPassword(value, _passwordController.text);
  },
),

This approach scales well. As you add more forms across your app—payment, profile, checkout—you reuse the same validators without duplication. It's also simpler to test validators in isolation.

Input formatters — Restrict and format input like phone numbers

InputFormatter instances shape data as the user types, before the validator ever sees it. Use them to enforce character limits, strip unwanted characters, or apply visual formatting. A phone number field that auto-formats to (123) 456-7890 is just formatter magic.

Flutter provides built-in formatters like FilteringTextInputFormatter and LengthLimitingTextInputFormatter. For custom formatting, extend TextInputFormatter:

dart
class PhoneInputFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final text = newValue.text.replaceAll(RegExp(r'\D'), '');
    
    if (text.isEmpty) {
      return newValue.copyWith(text: '');
    }
    
    final buffer = StringBuffer();
    for (int i = 0; i < text.length && i < 10; i++) {
      if (i == 3 || i == 6) {
        buffer.write(' ');
      }
      buffer.write(text[i]);
    }
    
    final formattedText = buffer.toString();
    return newValue.copyWith(
      text: formattedText,
      selection: TextSelection.collapsed(offset: formattedText.length),
    );
  }
}

Apply it to a phone field:

dart
TextFormField(
  decoration: InputDecoration(labelText: 'Phone Number'),
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    LengthLimitingTextInputFormatter(10),
    PhoneInputFormatter(),
  ],
  keyboardType: TextInputType.phone,
  validator: Validators.phone,
),

Now as users type, they see "123 456 7890" instead of raw digits. The formatter strips non-numeric input and applies the spacing automatically. The validator still runs independently, so you can reject incomplete numbers even if the format looks polished.

Submit states — Disable buttons and show progress while saving

A form isn't truly complete until it handles the submission lifecycle. After validation passes, you'll typically call an API, save to a database, or trigger some async work. During that time, disable the submit button and show feedback—otherwise users click repeatedly or navigate away thinking nothing happened.

Here's a complete form with submit state management:

dart
class SubmitForm extends StatefulWidget {
  @override
  State<SubmitForm> createState() => _SubmitFormState();
}

class _SubmitFormState extends State<SubmitForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  bool _isLoading = false;
  String? _errorMessage;

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

  Future<void> _submitForm() async {
    if (!_formKey.currentState!.validate()) {
      return;
    }

    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    try {
      // Simulate API call
      await Future.delayed(Duration(seconds: 2));
      
      // Success
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Form submitted successfully')),
        );
        _formKey.currentState!.reset();
        _emailController.clear();
      }
    } catch (e) {
      if (mounted) {
        setState(() {
          _errorMessage = 'Submission failed: ${e.toString()}';
        });
      }
    } finally {
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(labelText: 'Email'),
            validator: Validators.email,
            enabled: !_isLoading,
          ),
          if (_errorMessage != null)
            Padding(
              padding: EdgeInsets.symmetric(vertical: 12),
              child: Text(
                _errorMessage!,
                style: TextStyle(color: Colors.red),
              ),
            ),
          SizedBox(height: 20),
          _isLoading
            ? SizedBox(
                height: 36,
                child: Center(
                  child: SizedBox(
                    width: 24,
                    height: 24,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                ),
              )
            : ElevatedButton(
                onPressed: _submitForm,
                child: Text('Submit'),
              ),
        ],
      ),
    );
  }
}

Key points here:

  • Disable inputs during loading: Set enabled: !_isLoading on form fields to prevent accidental edits mid-submission.
  • Clear error state: Reset _errorMessage when a new submission starts.
  • Use mounted check: After an async operation, always verify the widget is still in the tree before calling setState.
  • Show progress feedback: Replace the button with a spinner, or use a ButtonState enum for more complex UX.
  • Reset on success: Call _formKey.currentState!.reset() to clear fields and validation errors after successful submission.

Common mistakes

Not disposing TextEditingController: Controllers hold references to listeners and resources. Always call dispose() in your State's dispose method, or use a TextEditingController provider if you're using a state management solution.

Validator logic in onChanged: Avoid triggering validation on every keystroke unless your UX explicitly calls for it. Instead, validate on focus loss or form submission. Constant error messages while typing frustrate users.

Ignoring cross-field dependencies: Password confirmation and other inter-field validations require access to sibling field values. Either pass them as parameters (like we did with confirmPassword) or use a form-level validator that checks multiple fields at once.

Forgetting to check mounted after async: If a user navigates away while a form submission is in flight, calling setState on a disposed widget crashes the app. Always guard setState calls with if (mounted).

Coupling validators to UI: Validators should return generic error strings, not locale-specific or context-aware messages. Let your presentation layer translate them if needed.

Key takeaways

  • Use Form with GlobalKey<FormState> to coordinate validation and state across multiple fields.
  • Extract validators into reusable functions for consistency, testability, and maintainability.
  • Apply InputFormatter to shape user input in real-time—for example, auto-formatting phone numbers or enforcing length limits.
  • Manage submit state explicitly: disable inputs, show progress indicators, handle errors, and reset on success.
  • Always dispose controllers and check mounted after async operations to avoid memory leaks and crashes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles