flutter
/

Flutter Form Validation – Complete Guide with Examples

Last Sync: Today

On this page

10
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

Flutter Form Validation – Complete Guide with Examples

Why Validate Forms?

Form validation ensures that user input meets the required criteria before it is submitted. It prevents invalid data from being sent to your backend, improves user experience by providing immediate feedback, and reduces bugs. In Flutter, the Form widget combined with TextFormField provides a powerful way to validate user input. This guide covers everything from basic required field validation to advanced async checks.

Basic Validation with TextFormField

TextFormField has a validator property that takes a function returning a String?. If the function returns a non‑null string, that string is displayed as an error below the field. Returning null means the field is valid.

DARTRead-only
1
TextFormField(
  decoration: InputDecoration(labelText: 'Name'),
  validator: (value) {
    if (value == null || value.isEmpty) {
      return 'Please enter your name';
    }
    return null;
  },
)

The validator is called automatically when the form is validated (e.g., _formKey.currentState!.validate()). It's also called on each change if autovalidateMode is set to AutovalidateMode.onUserInteraction.

Using a GlobalKey<FormState>

To validate a form, you need a GlobalKey<FormState>. This key gives you access to the form's state, allowing you to call validate() and save().

DARTRead-only
1
final _formKey = GlobalKey<FormState>();

Form(
  key: _formKey,
  child: Column(
    children: [
      TextFormField(
        validator: (value) => value!.isEmpty ? 'Required' : null,
      ),
      ElevatedButton(
        onPressed: () {
          if (_formKey.currentState!.validate()) {
            // all fields are valid
          }
        },
        child: Text('Submit'),
      ),
    ],
  ),
)

Common Validators

Here are reusable validator functions for common scenarios:

    • Required field: if (value == null || value.isEmpty) return 'Required';
    • Email validation: if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) return 'Invalid email';
    • Minimum length: if (value.length < 6) return 'At least 6 characters';
    • Number validation: if (double.tryParse(value) == null) return 'Must be a number';
    • Password confirmation: compare with another field's value (store both in state).

AutovalidateMode

autovalidateMode controls when validation errors are displayed. Options:

    • AutovalidateMode.disabled (default): Errors appear only after validate() is called (e.g., on submit).
    • AutovalidateMode.onUserInteraction: Errors appear as soon as the user starts typing or changes the field.
    • AutovalidateMode.always: Errors are always shown, even before user interaction (use with caution).
DARTRead-only
1
Form(
  key: _formKey,
  autovalidateMode: AutovalidateMode.onUserInteraction,
  child: ...
)

Custom FormField for Complex Validations

If you need validation for custom input (like a group of radio buttons or a date picker), you can create a FormField widget that manages its own validation state. Here's an example for a radio group:

DARTRead-only
1
class RadioFormField extends FormField<int> {
  RadioFormField({
    required int? initialValue,
    required FormFieldSetter<int> onSaved,
    required FormFieldValidator<int> validator,
  }) : super(
          initialValue: initialValue,
          onSaved: onSaved,
          validator: validator,
          builder: (FormFieldState<int> state) {
            return Column(
              children: [
                RadioListTile<int>(
                  value: 1,
                  groupValue: state.value,
                  title: Text('Option 1'),
                  onChanged: (value) => state.didChange(value),
                ),
                RadioListTile<int>(
                  value: 2,
                  groupValue: state.value,
                  title: Text('Option 2'),
                  onChanged: (value) => state.didChange(value),
                ),
                if (state.hasError)
                  Text(
                    state.errorText!,
                    style: TextStyle(color: Colors.red),
                  ),
              ],
            );
          },
        );
}

Async Validation

Sometimes you need to check a field against a server (e.g., username availability). You can't return a Future directly from validator, so you need to manage async validation manually. A common pattern is to use a StatefulWidget with a loading indicator and an error state, and call validation on user input.

DARTRead-only
1
class AsyncValidationExample extends StatefulWidget {
  @override
  _AsyncValidationExampleState createState() => _AsyncValidationExampleState();
}

class _AsyncValidationExampleState extends State<AsyncValidationExample> {
  final _formKey = GlobalKey<FormState>();
  final _usernameController = TextEditingController();
  String? _usernameError;
  bool _checking = false;

  Future<void> _checkUsername(String username) async {
    setState(() => _checking = true);
    await Future.delayed(Duration(seconds: 1)); // simulate network call
    final exists = username == 'admin'; // pretend check
    setState(() {
      _checking = false;
      _usernameError = exists ? 'Username already taken' : null;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _usernameController,
            decoration: InputDecoration(
              labelText: 'Username',
              errorText: _usernameError,
              suffixIcon: _checking
                  ? SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : null,
            ),
            onChanged: (value) => _checkUsername(value),
            validator: (value) {
              if (value == null || value.isEmpty) return 'Required';
              // async result will override this
              return null;
            },
          ),
          ElevatedButton(
            onPressed: () async {
              if (_formKey.currentState!.validate()) {
                await _checkUsername(_usernameController.text);
                if (_usernameError == null) {
                  // proceed
                }
              }
            },
            child: Text('Submit'),
          ),
        ],
      ),
    );
  }
}

Best Practices

    • Use reusable validator functions to keep code DRY.
    • Set autovalidateMode to onUserInteraction for immediate feedback, but be careful not to overwhelm the user with errors before they finish typing.
    • Provide clear, actionable error messages (e.g., 'Password must be at least 8 characters' rather than 'Invalid').
    • Validate on submit as well; even if you use onUserInteraction, always call validate() before submission to ensure all fields are valid.
    • Use GlobalKey<FormState> for each form, and don't reuse the same key across multiple forms.
    • For complex forms, consider using a state management solution like Provider or BLoC to separate validation logic from UI.

Common Mistakes

    • Not calling validate() before submission: The form may be submitted with invalid data.
    • Returning null from validator on error: The error message won't be shown.
    • Using null check without handling empty strings: value.isEmpty will throw if value is null; always check null first.
    • Not setting autovalidateMode: Errors only appear after first submit, which can be confusing.
    • Using the same GlobalKey for multiple forms: This will cause conflicts.
    • Forgetting to dispose controllers (if you use them in validation).

Key Takeaways

    • Use GlobalKey<FormState> to manage a form.
    • Define validator functions that return String? – null means valid.
    • Use autovalidateMode to control when errors appear.
    • Create reusable validators for common cases (email, required, etc.).
    • For custom widgets, use FormField to integrate validation.
    • Handle async validation with manual state management and loading indicators.

Try it yourself

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Validation Demo',
      home: Scaffold(
        appBar: AppBar(title: Text('Form Validation')),
        body: Padding(
          padding: const EdgeInsets.all(16.0),
          child: RegistrationForm(),
        ),
      ),
    );
  }
}

class RegistrationForm extends StatefulWidget {
  @override
  _RegistrationFormState 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();
  }

  String? _validateName(String? value) {
    if (value == null || value.isEmpty) return 'Name is required';
    return null;
  }

  String? _validateEmail(String? value) {
    if (value == null || value.isEmpty) return 'Email is required';
    final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    if (!regex.hasMatch(value)) return 'Enter a valid email';
    return null;
  }

  String? _validatePassword(String? value) {
    if (value == null || value.isEmpty) return 'Password is required';
    if (value.length < 6) return 'Password must be at least 6 characters';
    return null;
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Form submitted successfully!')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      autovalidateMode: AutovalidateMode.onUserInteraction,
      child: Column(
        children: [
          TextFormField(
            controller: _nameController,
            decoration: InputDecoration(labelText: 'Name'),
            validator: _validateName,
          ),
          SizedBox(height: 16),
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(labelText: 'Email'),
            keyboardType: TextInputType.emailAddress,
            validator: _validateEmail,
          ),
          SizedBox(height: 16),
          TextFormField(
            controller: _passwordController,
            decoration: InputDecoration(labelText: 'Password'),
            obscureText: true,
            validator: _validatePassword,
          ),
          SizedBox(height: 24),
          ElevatedButton(
            onPressed: _submit,
            child: Text('Register'),
          ),
        ],
      ),
    );
  }
}

Test Your Knowledge

Q1
of 4

What does the `validator` function in TextFormField return when the input is valid?

A
true
B
false
C
null
D
An empty string
Q2
of 4

Which property controls when validation errors are displayed?

A
validationMode
B
validateMode
C
autovalidateMode
D
errorDisplayMode
Q3
of 4

How do you trigger validation on all fields of a form?

A
_formKey.currentState!.check()
B
_formKey.currentState!.validate()
C
_formKey.validate()
D
Form.validateAll()
Q4
of 4

What does `AutovalidateMode.onUserInteraction` do?

A
Errors appear only on submit
B
Errors appear as the user types or changes the field
C
Errors appear always, even before typing
D
Errors never appear

Frequently Asked Questions

How do I clear validation errors after resetting a form?

Call _formKey.currentState!.reset() to reset the form and clear all fields and validation errors. If you need to manually clear errors for a specific field, you can use FormFieldState's reset or set the field's controller and call setState.

Can I validate a form without a GlobalKey?

You can, but you won't be able to call validate() programmatically. You would have to manage the validation state manually, which is more error‑prone. Using a key is the recommended approach.

How do I validate a field only when it's focused?

Combine FocusNode and listen to focus changes. When focus is lost, you can trigger validation. For this, you may need to store the field's validator and call it manually, or use a custom TextFormField with a focusNode listener.

What is the difference between `validator` and `onSaved`?

validator is used to check the field's value and return an error message; it's called when the form is validated. onSaved is called when _formKey.currentState!.save() is called, allowing you to store the field's value in a model.

Can I combine multiple validators for one field?

Yes, write a function that runs each validator and returns the first error, or use a package like validators. For example: validator: (value) => required(value) ?? email(value) ?? minLength(value, 6).

Previous

flutter texteditingcontroller

Next

flutter checkbox

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.