Flutter Form Validation: Build a Login Form with TextFormField

Validate user input in Flutter with Form, TextFormField and GlobalKey. Build a complete login form with email and password rules, a show/hide password button and a loading state.

By Yaqoob Developer · · 5 min read

  • #Flutter
  • #Forms
  • #Validation
  • #Login UI
  • #Beginners

A beautiful login screen is only half the job. If a user types "abc" as their email and taps Sign in, your app should tell them what is wrong before it ever talks to a server.

Flutter has validation built in. In this guide you will build a complete login form step by step:

  • Wrap fields in a Form and control it with a GlobalKey
  • Write validators for email and password
  • Show and hide the password
  • Validate as the user types, but only after their first attempt
  • Move between fields with the keyboard's Next button
  • Show a loading spinner while signing in

If you want the visual side first, see how to design a beautiful login screen. This post focuses on making the form work.

TextField or TextFormField?

  • TextField is a plain input.
  • TextFormField is a TextField that can join a Form and has a validator.

For anything that needs validation, use TextFormField.

Step 1: The Form and its key

A Form groups fields so you can validate them all at once. You talk to it through a GlobalKey:

import 'package:flutter/material.dart';

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

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Form(
          key: _formKey,
          child: ListView(
            padding: const EdgeInsets.all(24),
            children: const [
              // fields go here
            ],
          ),
        ),
      ),
    );
  }
}

Two important details:

  • Always dispose controllers, or they leak memory.
  • Use a ListView (not a Column) so the form can scroll when the keyboard opens on small phones.

Step 2: Validators

A validator is a function that receives the current text and returns:

  • null if the value is valid
  • an error message if it is not

Keep them as small functions so you can reuse them on sign-up and other screens:

String? validateEmail(String? value) {
  final email = value?.trim() ?? '';
  if (email.isEmpty) return 'Enter your email';
  final pattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
  if (!pattern.hasMatch(email)) return 'Enter a valid email address';
  return null;
}

String? validatePassword(String? value) {
  final password = value ?? '';
  if (password.isEmpty) return 'Enter your password';
  if (password.length < 8) return 'Use at least 8 characters';
  return null;
}

The email pattern is intentionally simple: something, @, something, dot, something. Perfect email regexes are very long and still reject real addresses. The server (for example Firebase Auth) does the final check.

Step 3: The fields

TextFormField(
  controller: _emailController,
  decoration: const InputDecoration(
    labelText: 'Email',
    prefixIcon: Icon(Icons.email_outlined),
    border: OutlineInputBorder(),
  ),
  keyboardType: TextInputType.emailAddress,
  textInputAction: TextInputAction.next,
  autofillHints: const [AutofillHints.email],
  validator: validateEmail,
),
const SizedBox(height: 16),
TextFormField(
  controller: _passwordController,
  decoration: InputDecoration(
    labelText: 'Password',
    prefixIcon: const Icon(Icons.lock_outline),
    border: const OutlineInputBorder(),
    suffixIcon: IconButton(
      icon: Icon(_obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined),
      onPressed: () => setState(() => _obscure = !_obscure),
    ),
  ),
  obscureText: _obscure,
  textInputAction: TextInputAction.done,
  autofillHints: const [AutofillHints.password],
  onFieldSubmitted: (_) => _submit(),
  validator: validatePassword,
),

Add bool _obscure = true; to the state class.

  • keyboardType: emailAddress shows the @ key on the keyboard.
  • textInputAction: next turns the Enter key into Next, which moves focus to the password field automatically.
  • autofillHints lets the phone's password manager fill both fields.
  • onFieldSubmitted on the last field signs in when the user presses Done.

Step 4: Submit and validate

bool _loading = false;
AutovalidateMode _autovalidate = AutovalidateMode.disabled;

Future<void> _submit() async {
  final valid = _formKey.currentState!.validate();
  if (!valid) {
    // From now on, re-check fields as the user types.
    setState(() => _autovalidate = AutovalidateMode.onUserInteraction);
    return;
  }

  FocusScope.of(context).unfocus(); // close the keyboard
  setState(() => _loading = true);
  try {
    await Future.delayed(const Duration(seconds: 2)); // call your API here
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Signed in!')),
    );
  } finally {
    if (mounted) setState(() => _loading = false);
  }
}

And pass the mode to the Form:

Form(
  key: _formKey,
  autovalidateMode: _autovalidate,
  child: // ...
)

validate() runs every validator, shows the error messages under the fields, and returns true only if all of them passed.

Why not validate from the start?

If you set autovalidateMode: AutovalidateMode.onUserInteraction from the beginning, the email field shows "Enter a valid email address" after the very first letter. That feels like the app is shouting at the user.

The pattern above is friendlier: no errors until the first Sign in tap, then live feedback while they fix things.

Step 5: The button with a loading state

const SizedBox(height: 24),
FilledButton(
  onPressed: _loading ? null : _submit,
  style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52)),
  child: _loading
      ? const SizedBox.square(
          dimension: 22,
          child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
        )
      : const Text('Sign in'),
),

Setting onPressed to null disables the button while loading, so a user who taps twice does not send two requests.

Step 6: Confirm password (sign-up forms)

A confirm field needs to compare with another field. Read the other controller inside the validator:

TextFormField(
  obscureText: true,
  decoration: const InputDecoration(labelText: 'Confirm password'),
  validator: (value) {
    if (value != _passwordController.text) return 'Passwords do not match';
    return null;
  },
),

Common mistakes

"Null check operator used on a null value" on _formKey.currentState!. The key is not attached to a Form. Make sure you passed key: _formKey to the Form, and that the button is inside the same screen.

The validator never runs. You used TextField instead of TextFormField, or the field is not a descendant of the Form.

Bottom overflowed by 200 pixels when the keyboard opens. Your fields are in a Column. Switch to ListView or wrap the Column in a SingleChildScrollView.

Creating the GlobalKey inside build. A new key on every rebuild resets the form. Create it once, as a field of the State class, like above.

Next steps

Now connect the form to a real backend with Firebase Authentication login and sign-up. For finished designs to use with this code, browse the login screens in my Flutter UI projects, like the minimal login screen or the phone number login UI.

Summary

  • Put TextFormFields inside a Form with a GlobalKey<FormState>.
  • Validators return null for valid input and a message otherwise.
  • Call _formKey.currentState!.validate() on submit, then switch to onUserInteraction mode.
  • Use textInputAction, autofillHints and a loading state to make the form feel professional.

Keep reading

More articles