TextField and TextFormField in Flutter: Complete Tutorial (2026)
Last Updated: | Author: Deep Singh
Almost every app needs some kind of text input — a login form, a search box, a signup page, a comment section. Flutter gives you two widgets for this: TextField and TextFormField. They look similar, but they serve slightly different purposes.
In this tutorial, you'll learn how to use both. We'll build a small form with a name field, an email field with validation, and a password field with a show/hide toggle. By the end, you'll know exactly when to reach for each one.
TextField vs TextFormField: What's the Difference?
Both widgets let users type text, but they differ in one important way:
- TextField is the basic input widget. It doesn't validate anything on its own. You handle validation manually.
- TextFormField is a
TextFieldwrapped in form-friendly plumbing. It works with theFormwidget and supports avalidatorcallback, which means you can validate multiple fields at once by callingformKey.currentState!.validate().
The rule of thumb: use TextField for simple standalone inputs (like a search bar), and TextFormField when you're building a form with multiple fields that need validation.
The Full Code
Here's a complete working example. It has two sections: a simple TextField for name input, and a Form containing two TextFormFields for email and password with validation.
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(
home: MyFieldTutorial(),
title: 'TextField Tutorial',
));
}
class MyFieldTutorial extends StatefulWidget {
const MyFieldTutorial({super.key});
@override
State<MyFieldTutorial> createState() => _MyFieldTutorialState();
}
class _MyFieldTutorialState extends State<MyFieldTutorial> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passController = TextEditingController();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
bool _passwordVisible = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TextField Tutorial'),
),
body: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 20),
const Text(
'TextField Example',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
// A plain TextField (no validation built in)
Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
child: TextField(
controller: _nameController,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Enter Name',
hintText: 'Enter Name',
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_nameController.text.isEmpty) {
debugPrint('Please enter name');
} else {
debugPrint('Name: ${_nameController.text}');
}
},
child: const Text('Submit Name'),
),
const SizedBox(height: 40),
const Text(
'TextFormField with Validation',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
// A Form with two TextFormFields
Form(
key: _formKey,
child: Column(
children: [
Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
child: TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
decoration: InputDecoration(
labelText: 'Enter Email',
hintText: 'Enter Email',
prefixIcon: const Icon(Icons.email),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 20),
Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
child: TextFormField(
controller: _passController,
obscureText: !_passwordVisible,
textInputAction: TextInputAction.done,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
decoration: InputDecoration(
labelText: 'Enter Password',
hintText: 'Enter Password',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_passwordVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_passwordVisible = !_passwordVisible;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
debugPrint('Email: ${_emailController.text}');
debugPrint('Password: ${_passController.text}');
_formKey.currentState!.reset();
_emailController.clear();
_passController.clear();
}
},
child: const Text('Submit Form'),
),
],
),
),
const SizedBox(height: 40),
],
),
),
);
}
}
How the Code Works
Let's break down the important pieces:
1. TextEditingController
Each input field has its own TextEditingController. The controller lets you read the current value (controller.text), set it programmatically, and clear it. Remember to dispose of them in dispose() to avoid memory leaks.
2. TextField vs TextFormField
The name field uses a plain TextField — no validation, just a simple input. The email and password fields use TextFormField, which supports the validator callback.
3. The Form Key
The GlobalKey<FormState> gives you access to the form's state. When you call _formKey.currentState!.validate(), Flutter runs the validator on every field in the form and returns true only if all validators pass.
4. Validators
A validator is a function that takes the current text and returns either null (meaning the value is valid) or a string (the error message to show). We check for empty input and, for the email, a basic @ check.
5. Password Visibility Toggle
The password field uses obscureText: !_passwordVisible to hide or show the text. The suffix IconButton flips the _passwordVisible variable, which triggers a rebuild with the new visibility state. We also swap the icon between visibility and visibility_off.
6. Clearing the Form
After a successful submit, we call _formKey.currentState!.reset() to clear the validation errors. Note that reset() doesn't clear the text controllers — you have to clear those manually with .clear().
Validation Tips
A few things to keep in mind when working with forms:
- Don't validate on every keystroke. By default,
TextFormFieldvalidates when the form is submitted or when the user leaves the field. You can change this withautovalidateMode, but for most forms the default behaviour is fine. - Use
textInputAction. SettingTextInputAction.nexton all but the last field lets the user jump between fields by pressing the keyboard's "next" button. - Set the right
keyboardType. UseTextInputType.emailAddressfor email fields,TextInputType.numberfor numeric input, and so on. This shows the right keyboard on mobile. - Wrap your form in a
SingleChildScrollView. On small screens, the keyboard can cover your input fields. Wrapping the column in a scroll view prevents overflow errors. - Dispose of controllers. Failing to dispose a
TextEditingControllerleaks memory. Always clean up indispose().
Wrapping Up
You now know how to use both TextField and TextFormField in Flutter. Use TextField for simple inputs and TextFormField when you need validation. With a Form and a GlobalKey<FormState>, you can validate all your fields with a single call.
From here, you can extend this further with features like form autofill, custom input formatters (for phone numbers or credit cards), and focus node management for smooth keyboard navigation.