EthicsComputerCourses
Join training +91 90532 10052

Forms & Validation

Flutter & Dart · Lesson 20 of 29 Intermediate 6 min read Updated

Sign-up screens, checkout pages, feedback forms and "add expense" screens all collect input. A good form tells users what is wrong and where, before anything is saved: "Enter a 10-digit mobile number" next to the mobile field is far better than a generic "Invalid input".

Flutter's Form widget groups fields so you can validate, save and reset them together. In this lesson you will build a complete admission form with text fields, a dropdown and a date picker, and you will write validators as plain Dart functions so you can test them.

Form, GlobalKey and TextFormField

A Form wraps several TextFormFields (and other form fields). Each field has a validator function: it receives the current text and returns an error message, or null if the value is fine. A GlobalKey<FormState> gives you access to the form so you can call validate().

Dart
import 'package:flutter/material.dart';

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

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _mobile = TextEditingController();

  @override
  void dispose() {
    _mobile.dispose();
    super.dispose();
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('OTP sent to ${_mobile.text}')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _mobile,
            keyboardType: TextInputType.phone,
            decoration: const InputDecoration(labelText: 'Mobile number', prefixText: '+91 '),
            validator: (value) {
              final v = value?.trim() ?? '';
              if (v.isEmpty) return 'Enter your mobile number';
              if (!RegExp(r'^[6-9]\d{9}$').hasMatch(v)) return 'Enter a valid 10-digit mobile number';
              return null;
            },
          ),
          FilledButton(onPressed: _submit, child: const Text('Send OTP')),
        ],
      ),
    );
  }
}

The form shows a "Mobile number" field with "+91 " in front of the cursor and a Send OTP button. Tapping the button with an empty field shows "Enter your mobile number" in red under the field. Typing 12345 shows "Enter a valid 10-digit mobile number". With 9876543210, the error disappears and a snack bar says "OTP sent to 9876543210".

validate() runs every field's validator, shows the messages, and returns true only if all of them returned null. currentState! is safe here because the key is attached to a Form that is on screen.

Validators are plain Dart

A validator is just a function String? Function(String?). Keep validators in their own file, without any Flutter imports, so you can reuse them in many forms and test them quickly. This file is pure Dart, so you can run it with dart run:

Dart
String? validateName(String? value) {
  final v = value?.trim() ?? '';
  if (v.isEmpty) return 'Name is required';
  if (v.length < 2) return 'Name is too short';
  return null;
}

String? validatePincode(String? value) {
  final v = value?.trim() ?? '';
  if (!RegExp(r'^[1-9]\d{5}$').hasMatch(v)) return 'Enter a 6-digit PIN code';
  return null;
}

String? Function(String?) minAmount(int min) {
  return (value) {
    final amount = int.tryParse(value ?? '');
    if (amount == null) return 'Enter an amount in rupees';
    if (amount < min) return 'Minimum amount is ₹$min';
    return null;
  };
}

void main() {
  print(validateName('  '));
  print(validateName('Isha'));
  print(validatePincode('012345'));
  print(validatePincode('302001'));

  final feeValidator = minAmount(500);
  print(feeValidator('abc'));
  print(feeValidator('200'));
  print(feeValidator('1500'));
}
Output
Name is required
null
Enter a 6-digit PIN code
null
Enter an amount in rupees
Minimum amount is ₹500
null

minAmount(500) returns a validator function, a closure that remembers min (see Functions). In a form you write validator: minAmount(500). Indian PIN codes do not start with 0, which is why the pattern begins with [1-9].

When to show errors: autovalidateMode

By default, errors appear only when you call validate(). Showing an error while the user is still typing their first character is annoying; never showing it until submit can be frustrating too. autovalidateMode: AutovalidateMode.onUserInteraction is a good middle ground: a field is checked as soon as the user has changed it, and re-checked on every change after that. You can set it on the whole Form or on single fields.

A complete form: formatters, dropdown and date

Real forms mix several kinds of fields. Here is an admission form for a coaching institute:

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

enum Batch { morning, evening, weekend }

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

  @override
  State<AdmissionForm> createState() => _AdmissionFormState();
}

class _AdmissionFormState extends State<AdmissionForm> {
  final _formKey = GlobalKey<FormState>();
  String _name = '';
  Batch? _batch;
  DateTime? _startDate;
  String _summary = '';

  Future<void> _pickDate() async {
    final now = DateTime.now();
    final picked = await showDatePicker(
      context: context,
      initialDate: _startDate ?? now,
      firstDate: now,
      lastDate: now.add(const Duration(days: 90)),
    );
    if (picked != null) setState(() => _startDate = picked);
  }

  void _submit() {
    final form = _formKey.currentState!;
    if (!form.validate() || _startDate == null) {
      if (_startDate == null) setState(() => _summary = 'Please choose a start date');
      return;
    }
    form.save();
    setState(() {
      final d = _startDate!;
      _summary = '$_name joins the ${_batch!.name} batch on ${d.day}/${d.month}/${d.year}';
    });
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      autovalidateMode: AutovalidateMode.onUserInteraction,
      child: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          TextFormField(
            decoration: const InputDecoration(labelText: 'Student name'),
            textInputAction: TextInputAction.next,
            validator: (v) => (v == null || v.trim().length < 2) ? 'Enter the full name' : null,
            onSaved: (v) => _name = v!.trim(),
          ),
          TextFormField(
            decoration: const InputDecoration(labelText: 'Parent mobile'),
            keyboardType: TextInputType.phone,
            inputFormatters: [
              FilteringTextInputFormatter.digitsOnly,
              LengthLimitingTextInputFormatter(10),
            ],
            validator: (v) => (v ?? '').length == 10 ? null : 'Enter 10 digits',
          ),
          DropdownButtonFormField<Batch>(
            initialValue: _batch,
            decoration: const InputDecoration(labelText: 'Batch'),
            items: const [
              DropdownMenuItem(value: Batch.morning, child: Text('Morning (7 to 9 am)')),
              DropdownMenuItem(value: Batch.evening, child: Text('Evening (5 to 7 pm)')),
              DropdownMenuItem(value: Batch.weekend, child: Text('Weekend')),
            ],
            onChanged: (b) => setState(() => _batch = b),
            validator: (b) => b == null ? 'Choose a batch' : null,
          ),
          ListTile(
            contentPadding: EdgeInsets.zero,
            title: Text(_startDate == null
                ? 'No start date chosen'
                : 'Starts on ${_startDate!.day}/${_startDate!.month}/${_startDate!.year}'),
            trailing: OutlinedButton(onPressed: _pickDate, child: const Text('Choose date')),
          ),
          FilledButton(onPressed: _submit, child: const Text('Submit admission')),
          Text(_summary),
        ],
      ),
    );
  }
}

The form has a name field, a mobile field that accepts only digits and stops at 10 characters, a "Batch" dropdown, a row with "No start date chosen" and a Choose date button, and a Submit admission button. Tapping submit with empty fields shows the error under each field. After filling everything in and choosing a date in the calendar dialog, the last line shows, for example, "Kabir joins the evening batch on 8/12/2026".

  • inputFormatters change what the user can type: FilteringTextInputFormatter.digitsOnly blocks letters, and LengthLimitingTextInputFormatter(10) stops at 10 characters. Formatters prevent bad input; validators explain what is still wrong. Use both.
  • onSaved runs for each field when you call form.save(), which is a tidy way to copy values out of a form without a controller per field.
  • DropdownButtonFormField takes initialValue (the older value parameter was deprecated in Flutter 3.35) and can have its own validator.
  • showDatePicker shows the Material calendar and returns the chosen DateTime, or null if the user cancels. initialDate is the day selected when the calendar opens; without it, no day is selected until the user taps one.
  • textInputAction: TextInputAction.next shows a "next" key on the keyboard that moves to the next field.

Other useful field options

  • obscureText: true hides passwords; add an icon button that toggles it so users can check what they typed.
  • maxLength shows a character counter; maxLines makes a field taller for comments.
  • form.reset() clears all fields back to their initial values.
  • FocusScope.of(context).unfocus() closes the keyboard, for example after submitting.
  • enabled: false makes a field read-only, such as a pre-filled roll number.

Common mistakes

  • Returning an empty string from a validator. '' counts as an error with no text. Return null for valid input.
  • Creating the GlobalKey inside build. A new key on every rebuild resets the form. Create it once as a field of the State.
  • Only validating on submit for long forms. Use AutovalidateMode.onUserInteraction so users see problems while they fill in the form.
  • Trusting int.parse on form text. Use int.tryParse inside validators.

Key points

  • Wrap fields in a Form with a GlobalKey<FormState>; call validate(), save() and reset() through it.
  • A validator returns an error message, or null when the value is valid.
  • Keep validators as plain Dart functions so they can be reused and unit-tested.
  • AutovalidateMode.onUserInteraction shows errors after the user has touched a field.
  • inputFormatters restrict typing; DropdownButtonFormField uses initialValue; showDatePicker returns a DateTime?.
  • Always validate again on the server.

Exercise

Write two pure Dart validators and test them with print: validateEmail, which requires an @ and a . after it (return 'Enter a valid email' otherwise), and validatePassword, which requires at least 8 characters and at least one digit (return a specific message for each problem). Then use both in a TextFormField-based sign-up form, with the password field hidden.

Show solution
Dart
String? validateEmail(String? value) {
  final v = value?.trim() ?? '';
  final at = v.indexOf('@');
  if (at < 1 || !v.substring(at).contains('.')) return 'Enter a valid email';
  return null;
}

String? validatePassword(String? value) {
  final v = value ?? '';
  if (v.length < 8) return 'Use at least 8 characters';
  if (!v.contains(RegExp(r'\d'))) return 'Add at least one number';
  return null;
}

void main() {
  print(validateEmail('ananya.gmail.com'));
  print(validateEmail('ananya@gmail.com'));
  print(validatePassword('chai123'));
  print(validatePassword('masalachai'));
  print(validatePassword('masalachai2'));
}
Output
Enter a valid email
null
Use at least 8 characters
Add at least one number
null

In the form, use them directly: TextFormField(validator: validateEmail, keyboardType: TextInputType.emailAddress) and TextFormField(validator: validatePassword, obscureText: true). Because the validators have no Flutter imports, you can also cover them with fast unit tests later.

Quick Quiz

1. What should a validator return when the value is valid?

2. What does _formKey.currentState!.validate() do?

3. Which option stops a user from typing letters into a number field?

4. Why should the GlobalKey<FormState> be a field of the State, not created in build?

Learn Flutter & Dart with a mentor

Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call