Lesson 15 of 77 beginner

Forms, Validation, Focus & Controllers

Input Flows That Don't Frustrate Users — Senior-Level UX Patterns

Open interactive version (quiz + challenge)

Real-world analogy

A Form is like a job application. Each FormField is a question on the form. The GlobalKey is the form's ID number — it lets the hiring manager (submit button) say 'review all answers now' (validate). FocusNode is like eye contact — it controls which field the user is currently 'talking to'. TextEditingController is like a notepad — it holds everything the user typed.

What is it?

Flutter forms use TextEditingController for text access, FocusNode for keyboard control, Form + GlobalKey for coordinated validation, and TextFormField for fields with built-in validation. Input formatters constrain input on keystrokes. AutovalidateMode controls when errors appear. Together they create UX-safe, production-quality input flows.

Real-world relevance

In a school management platform, a student enrollment form has: name field (letters only, required), email field (format validation, async duplicate check), phone (digits only, length 10-15), date of birth (date picker). On submit, form.validate() triggers all validators, scroll animates to first error, and a loading spinner appears in the Save button while the API call completes.

Key points

Code example

// Forms, Validation, Focus & Controllers

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

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

  @override
  State<EnrollmentForm> createState() => _EnrollmentFormState();
}

class _EnrollmentFormState extends State<EnrollmentForm> {
  // GlobalKey: lets us call validate(), save(), reset() from outside Form
  final _formKey = GlobalKey<FormState>();

  // Controllers: created in initState, disposed in dispose
  late final TextEditingController _nameController;
  late final TextEditingController _emailController;
  late final TextEditingController _passwordController;
  late final TextEditingController _confirmPasswordController;
  late final TextEditingController _phoneController;

  // FocusNodes: for keyboard navigation
  late final FocusNode _emailFocus;
  late final FocusNode _passwordFocus;
  late final FocusNode _confirmFocus;
  late final FocusNode _phoneFocus;

  bool _isLoading = false;
  bool _isPasswordVisible = false;
  Timer? _emailDebounce;
  String? _emailAsyncError;

  @override
  void initState() {
    super.initState();
    _nameController = TextEditingController();
    _emailController = TextEditingController();
    _passwordController = TextEditingController();
    _confirmPasswordController = TextEditingController();
    _phoneController = TextEditingController();

    _emailFocus = FocusNode();
    _passwordFocus = FocusNode();
    _confirmFocus = FocusNode();
    _phoneFocus = FocusNode();
  }

  @override
  void dispose() {
    // Dispose ALL controllers and FocusNodes
    _nameController.dispose();
    _emailController.dispose();
    _passwordController.dispose();
    _confirmPasswordController.dispose();
    _phoneController.dispose();

    _emailFocus.dispose();
    _passwordFocus.dispose();
    _confirmFocus.dispose();
    _phoneFocus.dispose();

    _emailDebounce?.cancel();
    super.dispose();
  }

  // Async validation with debounce — check email availability
  void _onEmailChanged(String email) {
    _emailDebounce?.cancel();
    _emailDebounce = Timer(const Duration(milliseconds: 500), () async {
      // Simulate API call
      final isTaken = await _checkEmailTaken(email);
      if (mounted) {
        setState(() {
          _emailAsyncError = isTaken ? 'Email already registered' : null;
        });
      }
    });
  }

  Future<bool> _checkEmailTaken(String email) async {
    await Future.delayed(const Duration(milliseconds: 300));
    return email == 'taken@example.com'; // Simulate
  }

  // Cross-field validation: confirm password must match
  String? _validateConfirmPassword(String? value) {
    if (value == null || value.isEmpty) return 'Please confirm your password';
    if (value != _passwordController.text) return 'Passwords do not match';
    return null; // Valid
  }

  Future<void> _handleSubmit() async {
    // Dismiss keyboard
    FocusScope.of(context).unfocus();

    // Validate all fields synchronously
    if (!_formKey.currentState!.validate()) {
      // TODO: Scroll to first error
      return;
    }

    // Save all fields (triggers onSaved callbacks)
    _formKey.currentState!.save();

    setState(() => _isLoading = true);

    try {
      // Simulate API call
      await Future.delayed(const Duration(seconds: 2));
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Enrolled successfully!')),
        );
      }
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      // Validate after user's first interaction — no errors on fresh form
      autovalidateMode: AutovalidateMode.onUserInteraction,
      child: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // --- NAME FIELD: letters only ---
          TextFormField(
            controller: _nameController,
            decoration: const InputDecoration(labelText: 'Full Name'),
            keyboardType: TextInputType.name,
            textInputAction: TextInputAction.next, // Shows 'next' on keyboard
            inputFormatters: [
              // Only allow letters and spaces
              FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z ]')),
            ],
            validator: (value) {
              if (value == null || value.trim().isEmpty) return 'Name is required';
              if (value.trim().length < 2) return 'Name too short';
              return null;
            },
            onFieldSubmitted: (_) {
              // Move focus to email on keyboard 'next' tap
              FocusScope.of(context).requestFocus(_emailFocus);
            },
          ),

          const SizedBox(height: 16),

          // --- EMAIL FIELD: async validation ---
          TextFormField(
            controller: _emailController,
            focusNode: _emailFocus,
            decoration: InputDecoration(
              labelText: 'Email',
              // Show async error below standard validator error
              errorText: _emailAsyncError,
            ),
            keyboardType: TextInputType.emailAddress,
            textInputAction: TextInputAction.next,
            autocorrect: false,
            onChanged: _onEmailChanged,
            validator: (value) {
              if (value == null || value.isEmpty) return 'Email is required';
              if (!RegExp(r'^[\w.-]+@[\w-]+\.[\w.]+$').hasMatch(value)) {
                return 'Invalid email format';
              }
              return null;
            },
            onFieldSubmitted: (_) {
              FocusScope.of(context).requestFocus(_passwordFocus);
            },
          ),

          const SizedBox(height: 16),

          // --- PASSWORD FIELD: obscure with toggle ---
          TextFormField(
            controller: _passwordController,
            focusNode: _passwordFocus,
            decoration: InputDecoration(
              labelText: 'Password',
              suffixIcon: IconButton(
                icon: Icon(_isPasswordVisible
                    ? Icons.visibility_off
                    : Icons.visibility),
                onPressed: () {
                  setState(() => _isPasswordVisible = !_isPasswordVisible);
                },
              ),
            ),
            obscureText: !_isPasswordVisible,
            textInputAction: TextInputAction.next,
            enableSuggestions: false,
            autocorrect: false,
            validator: (value) {
              if (value == null || value.isEmpty) return 'Password is required';
              if (value.length < 8) return 'At least 8 characters';
              if (!RegExp(r'[A-Z]').hasMatch(value)) return 'Include uppercase letter';
              if (!RegExp(r'[0-9]').hasMatch(value)) return 'Include a number';
              return null;
            },
            onFieldSubmitted: (_) {
              FocusScope.of(context).requestFocus(_confirmFocus);
            },
          ),

          const SizedBox(height: 16),

          // --- CONFIRM PASSWORD: cross-field validation ---
          TextFormField(
            controller: _confirmPasswordController,
            focusNode: _confirmFocus,
            decoration: const InputDecoration(labelText: 'Confirm Password'),
            obscureText: true,
            textInputAction: TextInputAction.next,
            enableSuggestions: false,
            validator: _validateConfirmPassword, // Captures passwordController
            onFieldSubmitted: (_) {
              FocusScope.of(context).requestFocus(_phoneFocus);
            },
          ),

          const SizedBox(height: 16),

          // --- PHONE FIELD: digits only, length constraint ---
          TextFormField(
            controller: _phoneController,
            focusNode: _phoneFocus,
            decoration: const InputDecoration(labelText: 'Phone'),
            keyboardType: TextInputType.phone,
            textInputAction: TextInputAction.done,
            inputFormatters: [
              FilteringTextInputFormatter.digitsOnly,
              LengthLimitingTextInputFormatter(15),
            ],
            validator: (value) {
              if (value == null || value.isEmpty) return 'Phone is required';
              if (value.length < 10) return 'Enter at least 10 digits';
              return null;
            },
            onFieldSubmitted: (_) => _handleSubmit(),
          ),

          const SizedBox(height: 24),

          // --- SUBMIT BUTTON: disabled while loading ---
          ElevatedButton(
            onPressed: _isLoading ? null : _handleSubmit,
            child: _isLoading
                ? const SizedBox(
                    height: 20,
                    width: 20,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Text('Enroll'),
          ),
        ],
      ),
    );
  }
}

Line-by-line walkthrough

  1. 1. GlobalKey: allows validate(), save(), reset() calls from submit button
  2. 2. Controllers and FocusNodes created in initState — NOT in build()
  3. 3. All controllers and FocusNodes disposed in dispose() — prevents memory leaks
  4. 4. _emailDebounce: waits 500ms after typing stops before making async API call
  5. 5. _validateConfirmPassword: closure captures _passwordController — cross-field validation
  6. 6. autovalidateMode: onUserInteraction — shows errors only after user types
  7. 7. FilteringTextInputFormatter.allow(RegExp): letters and spaces only on name field
  8. 8. focusNode on email field + onFieldSubmitted moves focus to password field
  9. 9. obscureText toggled by _isPasswordVisible state + visibility toggle button
  10. 10. enableSuggestions: false on password — prevents autocomplete on sensitive fields
  11. 11. LengthLimitingTextInputFormatter(15) + digitsOnly on phone
  12. 12. Submit button: null onPressed when loading disables it; shows spinner inside button

Spot the bug

class SignupForm extends StatefulWidget {
  const SignupForm({super.key});
  @override
  State<SignupForm> createState() => _SignupFormState();
}

class _SignupFormState extends State<SignupForm> {
  @override
  Widget build(BuildContext context) {
    final controller = TextEditingController();
    return TextField(controller: controller);
  }
}
Need a hint?
Where is the controller created and what happens on every rebuild?
Show answer
The TextEditingController is created inside build() — a new controller is created on EVERY rebuild. This means any text the user types is lost on each setState, and the old controllers are never disposed (memory leak). Fix: declare 'late final TextEditingController controller;' as a field, initialize in initState(), and call controller.dispose() in dispose().

Explain like I'm 5

A Form is like a paper application form. GlobalKey is the form's serial number — you need it to tell all the questions 'check your answers now.' TextEditingController is like a pencil that writes AND remembers everything you wrote. FocusNode is like a spotlight — it shines on the question you're currently answering. Input formatters are like rules: 'only write numbers here, no letters allowed'.

Fun fact

The most common Flutter form bug in production apps is forgetting to check 'mounted' before calling setState after an async validator completes. If the user submits and navigates away before the async validation finishes, the Future resolves and tries to setState on a disposed widget. The fix is always the same: check 'if (mounted)' before any setState inside an async callback.

Hands-on challenge

Build a multi-step form (2 steps) for a fintech app: Step 1 collects name + email (validated), Step 2 collects password + confirm password (cross-validated). Each step has its own Form with GlobalKey. 'Next' validates step 1 before advancing. 'Submit' validates step 2 and shows a summary.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery