Forms, Validation & User Input

Complete Form: Submission, Error Display & UX Polish

16 min Lesson 12 of 12

Complete Form: Submission, Error Display & UX Polish

In this final lesson of the Forms tutorial, we bring together every concept covered so far — TextFormField, FormKey, validators, controllers, and focus management — into a fully polished multi-field registration form. A production-quality form must do more than validate: it must show inline errors at the right moment, give visual feedback during an async submission, confirm success to the user, and reset cleanly so the form is ready again. Mastering these patterns will let you build forms that feel professional and trustworthy.

The Four Pillars of a Complete Form

  • Auto-validation mode — switch from AutovalidateMode.disabled to AutovalidateMode.onUserInteraction after the first submit attempt, so errors appear as the user corrects them.
  • Async submission with loading state — disable the button and show a spinner while the network call is in flight to prevent double-submits.
  • Success feedback — display a SnackBar or navigate away to confirm the action completed.
  • Form reset — call _formKey.currentState!.reset() and clear controllers after success so the form returns to its initial state.

Building the Registration Form Widget

The widget below is a complete, self-contained registration form with name, email, and password fields. Study the state variables: _autovalidateMode starts disabled and is promoted on the first invalid submit; _isLoading drives the button state; and the GlobalKey<FormState> provides access to validation and reset.

Complete Registration Form

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();

  bool _isLoading = false;
  bool _obscurePassword = true;
  AutovalidateMode _autovalidateMode = AutovalidateMode.disabled;

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

  // Simulates a network call (e.g. POST /register)
  Future<void> _submitForm() async {
    // First attempt: enable per-field validation feedback
    if (!_formKey.currentState!.validate()) {
      setState(() {
        _autovalidateMode = AutovalidateMode.onUserInteraction;
      });
      return;
    }

    setState(() => _isLoading = true);

    try {
      // Replace with your real API call
      await Future.delayed(const Duration(seconds: 2));

      if (!mounted) return;

      // Success feedback
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('Registration successful! Welcome aboard.'),
          backgroundColor: Colors.green,
        ),
      );

      // Reset the form and all controllers
      _formKey.currentState!.reset();
      _nameController.clear();
      _emailController.clear();
      _passwordController.clear();
      setState(() {
        _autovalidateMode = AutovalidateMode.disabled;
      });
    } catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('Error: ${e.toString()}'),
          backgroundColor: Colors.red,
        ),
      );
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Create Account')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(24),
        child: Form(
          key: _formKey,
          autovalidateMode: _autovalidateMode,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              TextFormField(
                controller: _nameController,
                decoration: const InputDecoration(
                  labelText: 'Full Name',
                  prefixIcon: Icon(Icons.person_outline),
                ),
                textInputAction: TextInputAction.next,
                validator: (value) {
                  if (value == null || value.trim().isEmpty) {
                    return 'Please enter your full name.';
                  }
                  if (value.trim().length < 2) {
                    return 'Name must be at least 2 characters.';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _emailController,
                decoration: const InputDecoration(
                  labelText: 'Email',
                  prefixIcon: Icon(Icons.email_outlined),
                ),
                keyboardType: TextInputType.emailAddress,
                textInputAction: TextInputAction.next,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter your email.';
                  }
                  final emailRegex = RegExp(
                    r'^[\w\-.]+@([\w\-]+\.)+[\w]{2,}$',
                  );
                  if (!emailRegex.hasMatch(value)) {
                    return 'Please enter a valid email address.';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _passwordController,
                decoration: InputDecoration(
                  labelText: 'Password',
                  prefixIcon: const Icon(Icons.lock_outline),
                  suffixIcon: IconButton(
                    icon: Icon(
                      _obscurePassword
                          ? Icons.visibility_off
                          : Icons.visibility,
                    ),
                    onPressed: () {
                      setState(() => _obscurePassword = !_obscurePassword);
                    },
                  ),
                ),
                obscureText: _obscurePassword,
                textInputAction: TextInputAction.done,
                onFieldSubmitted: (_) => _submitForm(),
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter a password.';
                  }
                  if (value.length < 8) {
                    return 'Password must be at least 8 characters.';
                  }
                  if (!RegExp(r'[A-Z]').hasMatch(value)) {
                    return 'Include at least one uppercase letter.';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 32),
              FilledButton(
                onPressed: _isLoading ? null : _submitForm,
                child: _isLoading
                    ? const SizedBox(
                        height: 20,
                        width: 20,
                        child: CircularProgressIndicator(
                          strokeWidth: 2,
                          color: Colors.white,
                        ),
                      )
                    : const Text('Create Account'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
Note: Always check mounted before calling setState() or ScaffoldMessenger inside an async method. If the user navigates away while the network call is in flight, the widget is disposed and accessing its context would throw an error.

AutovalidateMode in Depth

Flutter provides three modes via the AutovalidateMode enum:

  • AutovalidateMode.disabled — validators only run when you call validate() explicitly. Best as the initial state.
  • AutovalidateMode.onUserInteraction — validators re-run after every change, but only on fields the user has touched. This is the ideal post-first-submit mode: corrected fields get instant feedback without alarming fields the user has not yet visited.
  • AutovalidateMode.always — validators run on every rebuild even for untouched fields. Rarely appropriate for registration forms.
Tip: The two-phase pattern — start with disabled, flip to onUserInteraction on the first failed submit — is considered best practice in production Flutter apps. It avoids bombarding the user with error messages before they have had a chance to fill in anything.

The Loading Button Pattern

Passing null to a button's onPressed disables it visually and functionally — Flutter renders it in its disabled style automatically. Combine this with a compact CircularProgressIndicator inside the button to communicate that work is happening:

Loading Button State

// _isLoading controls both the disabled state and the child widget
FilledButton(
  onPressed: _isLoading ? null : _submitForm,
  child: _isLoading
      ? const SizedBox(
          height: 20,
          width: 20,
          child: CircularProgressIndicator(
            strokeWidth: 2,
            color: Colors.white,
          ),
        )
      : const Text('Create Account'),
)
Warning: Never call _formKey.currentState!.reset() alone after a successful submission. That resets the form validation state but does NOT clear the TextEditingController values. You must call controller.clear() (or controller.text = '') on each controller separately, or the text fields will still show the old text while the form thinks it is empty.

UX Polish Checklist

  • Use textInputAction: TextInputAction.next on all fields except the last, so the keyboard "Next" button moves focus automatically.
  • Set keyboardType appropriately (TextInputType.emailAddress, TextInputType.visiblePassword) so the correct keyboard appears.
  • Add a prefixIcon to each field for quick visual scanning.
  • Provide a show/hide password toggle via suffixIcon + obscureText.
  • Connect the last field's onFieldSubmitted callback to _submitForm() so pressing the keyboard "Done" key submits the form.
  • Wrap the form in SingleChildScrollView so it survives the keyboard pushing content up on smaller devices.

Summary

A complete form in Flutter combines: a GlobalKey<FormState> for programmatic control; TextEditingControllers for reading and resetting values; a two-phase AutovalidateMode for ergonomic error display; a _isLoading flag for async-safe submission; a mounted guard for async safety; and a thorough reset sequence (both form.reset() and controller.clear()) after success. Apply the UX polish checklist — correct keyboard types, focus chain, and a show/hide password toggle — and your form will be indistinguishable from those in top-tier production apps.