Skip to content
FlutterLearn

Intermediate · Lesson 3 · 11 min read

Forms, Validation and Input UX

Build forms with Form and TextFormField, write reusable validators, and get keyboard, focus and submission behaviour right.

Updated July 27, 2026

What you will learn

  • Use Form, GlobalKey and TextFormField together
  • Write composable, reusable validators
  • Manage focus order and keyboard actions
  • Handle async submission and server-side validation errors

Flutter's form system is small: a Form widget that coordinates, a GlobalKey<FormState> that gives you a handle on it, and TextFormFields that register themselves with the nearest Form.

A complete sign-up form

lib/screens/signup_screen.dart
class SignUpForm extends StatefulWidget {
  const SignUpForm({super.key});
  @override
  State<SignUpForm> createState() => _SignUpFormState();
}

class _SignUpFormState extends State<SignUpForm> {
  final _formKey = GlobalKey<FormState>();
  final _email = TextEditingController();
  final _password = TextEditingController();
  final _passwordFocus = FocusNode();

  bool _submitting = false;
  bool _obscure = true;
  String? _serverError;

  @override
  void dispose() {
    _email.dispose();
    _password.dispose();
    _passwordFocus.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    // Runs every field's validator and shows the messages
    if (!_formKey.currentState!.validate()) return;

    setState(() {
      _submitting = true;
      _serverError = null;
    });

    try {
      await auth.signUp(email: _email.text.trim(), password: _password.text);
      if (!mounted) return;
      Navigator.of(context).pushReplacementNamed('/home');
    } on ApiException catch (e) {
      if (!mounted) return;
      setState(() => _serverError = e.message);
    } finally {
      if (mounted) setState(() => _submitting = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      // Only show errors after the first submit attempt
      autovalidateMode: AutovalidateMode.onUserInteraction,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextFormField(
            controller: _email,
            keyboardType: TextInputType.emailAddress,
            textInputAction: TextInputAction.next,
            autofillHints: const [AutofillHints.email],
            decoration: const InputDecoration(
              labelText: 'Email',
              prefixIcon: Icon(Icons.mail_outline),
            ),
            validator: validateEmail,
            onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
          ),
          const SizedBox(height: 16),
          TextFormField(
            controller: _password,
            focusNode: _passwordFocus,
            obscureText: _obscure,
            textInputAction: TextInputAction.done,
            autofillHints: const [AutofillHints.newPassword],
            decoration: InputDecoration(
              labelText: 'Password',
              prefixIcon: const Icon(Icons.lock_outline),
              suffixIcon: IconButton(
                icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
                onPressed: () => setState(() => _obscure = !_obscure),
              ),
            ),
            validator: validatePassword,
            onFieldSubmitted: (_) => _submit(),
          ),
          if (_serverError != null) ...[
            const SizedBox(height: 12),
            Text(
              _serverError!,
              style: TextStyle(color: Theme.of(context).colorScheme.error),
            ),
          ],
          const SizedBox(height: 24),
          FilledButton(
            onPressed: _submitting ? null : _submit,
            child: _submitting
                ? const SizedBox(
                    height: 20,
                    width: 20,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Text('Create account'),
          ),
        ],
      ),
    );
  }
}

Reusable validators

A validator is just a function from String? to String? — return null when valid, a message when not. Because they are plain functions, you can compose and unit-test them without a widget tree.

lib/validation/validators.dart
typedef Validator = String? Function(String?);

String? required_(String? value) =>
    (value == null || value.trim().isEmpty) ? 'This field is required' : null;

String? validateEmail(String? value) {
  if (value == null || value.trim().isEmpty) return 'Email is required';
  final pattern = RegExp(r'^[\w.+-]+@[\w-]+\.[\w.-]+$');
  return pattern.hasMatch(value.trim()) ? null : 'Enter a valid email';
}

String? validatePassword(String? value) {
  if (value == null || value.isEmpty) return 'Password is required';
  if (value.length < 8) return 'Use at least 8 characters';
  if (!value.contains(RegExp(r'[0-9]'))) return 'Include at least one number';
  return null;
}

/// Runs validators in order and returns the first failure.
Validator compose(List<Validator> validators) {
  return (value) {
    for (final validate in validators) {
      final error = validate(value);
      if (error != null) return error;
    }
    return null;
  };
}

// Usage
validator: compose([required_, validateEmail]),

Keyboard and focus details

  • textInputAction: TextInputAction.next turns the keyboard's return key into a Next button; .done submits.
  • autofillHints lets password managers and the OS fill fields — a genuine conversion win, and free.
  • keyboardType picks the right keyboard: emailAddress, phone, number, multiline.
  • Wrap the form in a SingleChildScrollView so the keyboard never covers the submit button.
  • TextInputFormatters restrict input at the source — digits only, length caps, currency masks.
Dart
TextFormField(
  keyboardType: TextInputType.number,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    LengthLimitingTextInputFormatter(6),
  ],
  decoration: const InputDecoration(labelText: 'Verification code'),
)

Saving values with onSaved

If you prefer not to keep a controller per field, onSaved plus _formKey.currentState!.save() collects values in one pass.

Dart
String _name = '';

TextFormField(
  decoration: const InputDecoration(labelText: 'Full name'),
  validator: required_,
  onSaved: (value) => _name = value?.trim() ?? '',
)

void _submit() {
  final form = _formKey.currentState!;
  if (!form.validate()) return;
  form.save();          // triggers every onSaved
  submitToServer(_name);
}

Use controllers when you need to read or change the text as the user types; use onSaved for simple forms you only read at submission time.

Preventing accidental data loss

Dart
PopScope(
  canPop: !_hasUnsavedChanges,
  onPopInvokedWithResult: (didPop, result) async {
    if (didPop) return;
    final leave = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Discard changes?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('Keep editing'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('Discard'),
          ),
        ],
      ),
    );
    if (leave == true && context.mounted) Navigator.pop(context);
  },
  child: form,
)

Key takeaways

  • Form + GlobalKey<FormState> coordinates validation; validators are plain testable functions.
  • AutovalidateMode.onUserInteraction avoids yelling at users before they have typed anything.
  • Set textInputAction, keyboardType and autofillHints — small details, large UX difference.
  • Always disable the submit button while a submission is in flight.

Practice

Checkout form

Build a checkout form with name, email, address, card number (digits only, formatted in groups of four) and expiry (MM/YY). Validate each field, wire focus so the return key advances, block navigation away with unsaved changes, and unit-test every validator.

Show hints
  • A custom TextInputFormatter can insert spaces as the user types.
  • Validators are pure functions — test them with flutter test, no widgets required.