flutter
/

Flutter Form – Complete Guide with Validation and Submission

Last Sync: Today

On this page

14
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

Flutter Form – Complete Guide with Validation and Submission

What is a Form in Flutter?

A Form widget in Flutter is a container for grouping and validating multiple form fields. It helps manage the state of text fields, performs validation, and handles submission. The Form widget works in conjunction with TextFormField (or custom form field widgets) and a GlobalKey<FormState> to access the form's state. Forms are essential for collecting user input, such as login credentials, registration details, or any data that requires validation.

Basic Structure of a Form

A typical form consists of:

    • A GlobalKey<FormState> to uniquely identify the form and access its methods.
    • A Form widget that contains the form fields.
    • TextFormField widgets (or other form fields) with validator functions.
    • A button to submit or validate the form.
DARTRead-only
1
final _formKey = GlobalKey<FormState>();

Form(
  key: _formKey,
  child: Column(
    children: [
      TextFormField(
        validator: (value) {
          if (value == null || value.isEmpty) {
            return 'Please enter some text';
          }
          return null;
        },
      ),
      ElevatedButton(
        onPressed: () {
          if (_formKey.currentState!.validate()) {
            // Form is valid, proceed
          }
        },
        child: Text('Submit'),
      ),
    ],
  ),
)

TextFormField Properties

    • controller: A TextEditingController to control and retrieve text content.
    • validator: A function that returns a String?; if non‑null, it shows an error.
    • onSaved: Called when form.currentState!.save() is called, allowing you to store the field's value.
    • decoration: InputDecoration to customize appearance (labels, icons, error text).
    • keyboardType: Sets the keyboard type (e.g., TextInputType.emailAddress).
    • obscureText: For password fields.
    • autofocus: Focuses the field when the form appears.

InputDecoration and Styling

InputDecoration allows you to add labels, hint text, prefix icons, error messages, and borders. Here's an example of a well‑styled text field:

DARTRead-only
1
TextFormField(
  decoration: InputDecoration(
    labelText: 'Email',
    hintText: 'Enter your email',
    prefixIcon: Icon(Icons.email),
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    errorStyle: TextStyle(color: Colors.red),
  ),
  validator: (value) {
    if (value == null || value.isEmpty) return 'Email is required';
    if (!value.contains('@')) return 'Enter a valid email';
    return null;
  },
)

Common Built‑in Validators

Here are some common validation patterns you can use in validator functions:

    • 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';
    • Numeric only: if (double.tryParse(value) == null) return 'Must be a number';
    • Password confirmation: Compare two fields by storing values in the state.

Custom Validators

You can create reusable validator functions to keep your code DRY. For example:

DARTRead-only
1
String? validateEmail(String? value) {
  if (value == null || value.isEmpty) return 'Email is required';
  if (!value.contains('@')) return 'Invalid email format';
  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;
}

// Usage
TextFormField(validator: validateEmail);
TextFormField(validator: validatePassword);

Async Validation

Sometimes you need to check a field against a remote server (e.g., username availability). You can make the validator async and return a Future<String?>. To integrate with Form, you'll need to manage the loading state manually. Here's a pattern using FutureBuilder or a stateful approach:

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

class _AsyncValidatorExampleState extends State<AsyncValidatorExample> {
  final _formKey = GlobalKey<FormState>();
  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(
            decoration: InputDecoration(
              labelText: 'Username',
              errorText: _usernameError,
              suffixIcon: _checking
                  ? SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : null,
            ),
            onChanged: (value) {
              if (_usernameError != null) _checkUsername(value);
            },
            onFieldSubmitted: (value) => _checkUsername(value),
            validator: (value) {
              if (value == null || value.isEmpty) return 'Required';
              return null; // will be overridden by async result
            },
          ),
          ElevatedButton(
            onPressed: () async {
              if (_formKey.currentState!.validate()) {
                // Also wait for async validation if pending
                await _checkUsername(_formKey.currentState!.fields.first.controller!.text);
                if (_usernameError == null) {
                  // proceed
                }
              }
            },
            child: Text('Submit'),
          ),
        ],
      ),
    );
  }
}

Form Submission and Saving

After validation, you can retrieve the values using onSaved callbacks or directly from TextEditingControllers. A common pattern is to use onSaved to populate a model class:

DARTRead-only
1
class User {
  String? name;
  String? email;
}

final _formKey = GlobalKey<FormState>();
final _user = User();

Form(
  key: _formKey,
  child: Column(
    children: [
      TextFormField(
        onSaved: (value) => _user.name = value,
        validator: (value) => value == null || value.isEmpty ? 'Required' : null,
      ),
      TextFormField(
        onSaved: (value) => _user.email = value,
        validator: validateEmail,
      ),
      ElevatedButton(
        onPressed: () {
          if (_formKey.currentState!.validate()) {
            _formKey.currentState!.save();
            // Now _user contains the values
            print('User: ${_user.name}, ${_user.email}');
          }
        },
        child: Text('Submit'),
      ),
    ],
  ),
)

Focus Management

To move focus from one field to another automatically (e.g., pressing 'Next' on keyboard), use FocusNodes and FocusScope.

DARTRead-only
1
final _focusName = FocusNode();
final _focusEmail = FocusNode();

@override
void dispose() {
  _focusName.dispose();
  _focusEmail.dispose();
  super.dispose();
}

// In the build method:
TextFormField(
  focusNode: _focusName,
  textInputAction: TextInputAction.next,
  onFieldSubmitted: (_) => FocusScope.of(context).requestFocus(_focusEmail),
)
TextFormField(
  focusNode: _focusEmail,
  textInputAction: TextInputAction.done,
  onFieldSubmitted: (_) => _formKey.currentState!.validate(),
)

Dynamic Forms (Adding/Removing Fields)

For forms with a dynamic number of fields (e.g., multiple email addresses), you can maintain a list of controllers and rebuild the form. Use a List of TextEditingControllers and FocusNodes, and update the UI when the list changes. Remember to dispose of them when removed.

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

class _DynamicFormState extends State<DynamicForm> {
  final _formKey = GlobalKey<FormState>();
  final List<TextEditingController> _controllers = [TextEditingController()];
  final List<FocusNode> _focusNodes = [FocusNode()];

  void _addField() {
    setState(() {
      _controllers.add(TextEditingController());
      _focusNodes.add(FocusNode());
    });
  }

  void _removeField(int index) {
    setState(() {
      _controllers[index].dispose();
      _focusNodes[index].dispose();
      _controllers.removeAt(index);
      _focusNodes.removeAt(index);
    });
  }

  @override
  void dispose() {
    for (var c in _controllers) c.dispose();
    for (var f in _focusNodes) f.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          for (int i = 0; i < _controllers.length; i++)
            Row(
              children: [
                Expanded(
                  child: TextFormField(
                    controller: _controllers[i],
                    focusNode: _focusNodes[i],
                    validator: (value) => value!.isEmpty ? 'Required' : null,
                  ),
                ),
                IconButton(
                  icon: Icon(Icons.remove_circle),
                  onPressed: () => _removeField(i),
                ),
              ],
            ),
          ElevatedButton(
            onPressed: _addField,
            child: Text('Add Field'),
          ),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                final values = _controllers.map((c) => c.text).toList();
                print(values);
              }
            },
            child: Text('Submit'),
          ),
        ],
      ),
    );
  }
}

Advanced: Form with BLoC or Provider

For complex forms, consider using a state management solution. For example, with BLoC, you can emit validation states, loading states, and submission results. Here's a simplified pattern using ValueNotifier or ChangeNotifier to separate form logic from UI.

DARTRead-only
1
class FormModel extends ChangeNotifier {
  String? name;
  String? email;
  String? nameError;
  String? emailError;

  void validateName(String value) {
    nameError = value.isEmpty ? 'Name required' : null;
    name = value;
    notifyListeners();
  }

  void validateEmail(String value) {
    final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    emailError = !regex.hasMatch(value) ? 'Invalid email' : null;
    email = value;
    notifyListeners();
  }

  bool isValid() => nameError == null && emailError == null;

  void submit() {
    if (isValid()) {
      // submit data
    }
  }
}

// In UI, use ChangeNotifierProvider and Consumer

Best Practices

    • Always use a GlobalKey<FormState> to reference the form.
    • Use onSaved instead of reading controllers after submission for cleaner separation.
    • Provide clear, actionable error messages.
    • Use InputDecoration's errorText for asynchronous validation.
    • Dispose of TextEditingControllers and FocusNodes to avoid memory leaks.
    • For large forms, consider splitting into smaller widgets to improve performance.
    • Test validation logic with unit tests.

Common Mistakes

    • Not using a GlobalKey for the form – you won't be able to call validate() or save().
    • Forgetting to dispose controllers – causes memory leaks.
    • Using TextFormField without a Form ancestor – validation won't work.
    • Validating fields on every keystroke without debouncing – can cause performance issues, especially with async validation.
    • Not handling null values in validators – always check for null before accessing value.length, etc.
    • Hardcoding error messages – consider using a localization package for multi‑language apps.

Key Takeaways

    • Use Form and GlobalKey<FormState> to manage form state.
    • Each TextFormField can have a validator and onSaved.
    • InputDecoration customizes appearance and error display.
    • Use FocusNodes and FocusScope for smooth keyboard navigation.
    • Dynamic forms require careful management of controllers and focus nodes.
    • For complex state, integrate with BLoC or Provider.
    • Always clean up resources to prevent memory leaks.

Try it yourself

import 'package:flutter/material.dart';

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

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

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

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

  @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? _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 _submitForm() {
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Form submitted successfully!')),
      );
      // Clear fields after submission
      _formKey.currentState!.reset();
      _nameController.clear();
      _emailController.clear();
      _passwordController.clear();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextFormField(
            controller: _nameController,
            decoration: InputDecoration(
              labelText: 'Full Name',
              prefixIcon: Icon(Icons.person),
              border: OutlineInputBorder(),
            ),
            validator: (value) =>
                value!.isEmpty ? 'Please enter your name' : null,
          ),
          const SizedBox(height: 16),
          TextFormField(
            controller: _emailController,
            decoration: InputDecoration(
              labelText: 'Email',
              prefixIcon: Icon(Icons.email),
              border: OutlineInputBorder(),
            ),
            keyboardType: TextInputType.emailAddress,
            validator: _validateEmail,
          ),
          const SizedBox(height: 16),
          TextFormField(
            controller: _passwordController,
            decoration: InputDecoration(
              labelText: 'Password',
              prefixIcon: Icon(Icons.lock),
              border: OutlineInputBorder(),
            ),
            obscureText: true,
            validator: _validatePassword,
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: _submitForm,
            child: const Text('Register'),
          ),
        ],
      ),
    );
  }
}

Test Your Knowledge

Q1
of 4

What is the purpose of a GlobalKey<FormState>?

A
To uniquely identify the form and access its state methods
B
To store the form's data
C
To handle keyboard focus
D
To apply a theme to the form
Q2
of 4

Which method is called to trigger validation of all form fields?

A
_formKey.currentState.check()
B
_formKey.currentState.validate()
C
_formKey.currentState.valid()
D
_formKey.validate()
Q3
of 4

What does the `onSaved` callback do?

A
Saves the field's value to a variable
B
Triggers when the form is submitted
C
Validates the field asynchronously
D
Focuses the next field
Q4
of 4

How do you move focus to the next field when the user presses 'Next' on the keyboard?

A
Set `textInputAction: TextInputAction.next` and use `FocusScope.of(context).nextFocus()`
B
It happens automatically
C
Use `FocusNode` and `onFieldSubmitted`
D
Both A and C are correct

Frequently Asked Questions

What's the difference between `Form` and `FormField`?

Form is a container widget that groups FormField widgets (like TextFormField). FormField is a base class for custom form fields. You'll rarely use FormField directly; use TextFormField or other built‑in fields.

How do I reset a form after submission?

Call _formKey.currentState!.reset() to clear all fields and reset their validation state. You can also manually clear controllers if you need custom behavior.

Can I use `Form` without a `GlobalKey`?

You can, but then you cannot call validate() or save() because you won't have a reference to the form's state. It's strongly recommended to use a key.

How do I show validation errors only after the user has interacted with a field?

By default, TextFormField shows errors only after the user has attempted to submit the form (if autovalidateMode is AutovalidateMode.disabled). To show errors on user interaction, set autovalidateMode: AutovalidateMode.onUserInteraction on the Form or on each TextFormField.

How do I handle a form with many fields efficiently?

Consider breaking the form into smaller widgets using StatefulWidget and using Provider or Bloc to manage state. Also use const widgets where possible to avoid unnecessary rebuilds.

Previous

flutter streambuilder

Next

flutter textfield

Related Content

Need help?

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