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
· 5 min read
Flutter Bottom Navigation Bar with Material 3 (NavigationBar Guide)
Build a bottom navigation bar in Flutter with the Material 3 NavigationBar widget: switch screens, keep each tab's state with IndexedStack, add badges and style it to match your app.
· 8 min read
Agentik: The OpenRouter for AI Agent Tools
Agentik gives AI agents access to more than 2,000 tools through one connection, with a shared balance and pay-as-you-go billing.
· 9 min read
Dart Basics for Flutter Beginners: Variables, Functions and Classes
Learn the Dart you need for Flutter: variables, final and const, null safety, lists and maps, if and loops, functions with named parameters, classes, and async/await, with runnable examples.