Every real Flutter app takes input from the user. Here’s the right way to handle it from day one.
Login screens, signup pages, search boxes, profile editors, checkout forms — they all depend on the same two widgets: TextField and TextFormField. Flutter makes basic text input easy, but validation — catching empty fields, wrong formats, and short passwords before they reach your server — requires a specific pattern using Form, GlobalKey, and validator().
This guide covers everything a beginner needs: when to use TextField vs TextFormField, how the Form + GlobalKey validation flow works, how to pass data using controllers, how to validate while typing, and the mistakes that trip up almost every beginner. Every section has a complete, copy-paste ready code example.
StatefulWidget and setState(). If not, read our Stateless vs Stateful Widgets guide first — all the form examples here use StatefulWidget.
Table of Contents
1. TextField vs TextFormField: Which to Use
Flutter gives you two text input widgets. They look identical on screen but serve different purposes:
| Feature | TextField |
TextFormField |
|---|---|---|
| Basic text input | ✅ Yes | ✅ Yes |
Works inside a Form |
❌ No | ✅ Yes |
Built-in validator() support |
❌ No | ✅ Yes |
| Inline error messages | Manual only | ✅ Automatic |
TextEditingController |
✅ Yes | ✅ Yes |
| Best for | Search boxes, chat input, live previews | Login, signup, checkout, any validated form |
TextField TextFormField inside Form
────────────────────────────────── ──────────────────────────────────
User types User types
↓ ↓
onChanged / controller listener validator() runs on submit
↓ ↓
You handle everything manually Form checks ALL fields at once
↓
Show errors OR submit
TextFormField inside a Form. For everything else (search, chat, live preview), TextField with a controller is simpler and cleaner.
2. Basic TextField with a Controller
Before getting into forms, it’s worth understanding TextField and TextEditingController on their own. A controller lets you read the current value, set it programmatically, clear it, and listen for every change.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: PlainTextFieldPage()));
class PlainTextFieldPage extends StatefulWidget {
const PlainTextFieldPage({super.key});
@override
State<PlainTextFieldPage> createState() => _PlainTextFieldPageState();
}
class _PlainTextFieldPageState extends State<PlainTextFieldPage> {
// 1. Create the controller
final _controller = TextEditingController();
String _preview = '';
int _charCount = 0;
@override
void initState() {
super.initState();
// 2. Add a listener — fires every time text changes
_controller.addListener(() {
setState(() {
_preview = _controller.text;
_charCount = _controller.text.length;
});
});
}
@override
void dispose() {
// 3. Always dispose — prevents memory leaks
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('TextField + Controller')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _controller,
maxLength: 100,
decoration: const InputDecoration(
labelText: 'Type something',
hintText: 'Start typing to see live preview...',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.edit),
),
),
const SizedBox(height: 16),
// Live preview panel
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Live preview:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
const SizedBox(height: 4),
Text(_preview.isEmpty ? '(nothing typed yet)' : _preview),
const SizedBox(height: 4),
Text('$_charCount characters',
style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: () => _controller.clear(),
child: const Text('Clear'),
),
const SizedBox(width: 12),
OutlinedButton(
// Prefill the field programmatically
onPressed: () => _controller.text = 'Hello, Flutter!',
child: const Text('Prefill'),
),
],
),
],
),
),
);
}
}
| Controller method | What it does | When to use |
|---|---|---|
_controller.text |
Read current value | On submit button press |
_controller.text = 'value' |
Set/prefill value | Loading saved data into a field |
_controller.clear() |
Empty the field | After successful submit, reset |
_controller.addListener(fn) |
Fire callback on every change | Live search, character counters |
_controller.dispose() |
Free memory when widget removes | Always — in dispose() override |
3. The Form + GlobalKey Validation Pattern
This is the official Flutter pattern for form validation, and understanding it once means you can build any validated form. There are four moving parts:
| Part | What it is | Role in the pattern |
|---|---|---|
Form |
Widget | Container that groups all fields; owns the validation state |
GlobalKey<FormState> |
Key object | Unique handle to access the Form’s state from your code |
TextFormField |
Widget | Individual field with a validator callback |
_formKey.currentState!.validate() |
Method call | Triggers all validators; returns true if all pass |
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: SimpleFormPage()));
class SimpleFormPage extends StatefulWidget {
const SimpleFormPage({super.key});
@override
State<SimpleFormPage> createState() => _SimpleFormPageState();
}
class _SimpleFormPageState extends State<SimpleFormPage> {
// Step 1: Create the GlobalKey — this is the "remote control" for your Form
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
@override
void dispose() {
_nameController.dispose(); // Always dispose controllers
super.dispose();
}
void _submitForm() {
// Step 3: Call validate() — runs every field's validator at once
// Returns true only if ALL validators return null
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Welcome, ${_nameController.text.trim()}!'),
backgroundColor: Colors.green,
),
);
}
// If validate() returns false, each field shows its own error message
// automatically — you don't have to do anything else
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Simple Form Validation')),
body: Padding(
padding: const EdgeInsets.all(16),
child: // Step 2: Wrap fields in Form and attach the key
Form(
key: _formKey, // ← connect key to Form
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Your name',
hintText: 'e.g. Priya Sharma',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
// validator: return String for error, null for valid
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your name'; // ← shown as error text
}
if (value.trim().length < 3) {
return 'Name must be at least 3 characters';
}
if (value.trim().length > 50) {
return 'Name is too long';
}
return null; // ← null means valid ✅
},
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('Submit', style: TextStyle(fontSize: 16)),
),
],
),
),
),
);
}
}
String → field is invalid, the string is shown as the error message below the field. Return null → field is valid, no error shown. That’s the entire API.
4. Real Example: Login Form with Email & Password Validation
The most common beginner form — two fields, two validators, one submit button. Notice how each field validates independently, and validate() checks both at once when the button is pressed.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: LoginFormPage()));
class LoginFormPage extends StatefulWidget {
const LoginFormPage({super.key});
@override
State<LoginFormPage> createState() => _LoginFormPageState();
}
class _LoginFormPageState extends State<LoginFormPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _passwordVisible = false; // toggles the eye icon
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
// Simple email check — good enough for beginner validation
bool _isValidEmail(String value) {
return value.contains('@') && value.contains('.');
}
void _login() {
if (_formKey.currentState!.validate()) {
// Both fields passed — safe to proceed
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Logging in as ${_emailController.text.trim()}...'),
backgroundColor: Colors.green,
),
);
// In a real app, call your auth service here
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Welcome back',
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// ── Email field ───────────────────────────────────
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
// Disable autocorrect for email/password fields
autocorrect: false,
enableSuggestions: false,
decoration: const InputDecoration(
labelText: 'Email address',
hintText: '[email protected]',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
if (!_isValidEmail(value.trim())) {
return 'Enter a valid email address';
}
return null;
},
),
const SizedBox(height: 16),
// ── Password field ────────────────────────────────
TextFormField(
controller: _passwordController,
obscureText: !_passwordVisible, // hides characters when true
decoration: InputDecoration(
labelText: 'Password',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
// Eye icon to toggle password visibility
suffixIcon: IconButton(
icon: Icon(
_passwordVisible
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_passwordVisible = !_passwordVisible;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 24),
// ── Submit button ─────────────────────────────────
ElevatedButton(
onPressed: _login,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('Log In', style: TextStyle(fontSize: 16)),
),
const SizedBox(height: 12),
TextButton(
onPressed: () {},
child: const Text('Forgot password?'),
),
],
),
),
),
);
}
}
obscureText: !_passwordVisible hides the password by default, and the suffixIcon eye button toggles it. This is the standard pattern used in almost every real login screen.
5. Real Example: Signup Form with 4 Fields
A signup form adds two new challenges: confirming the password matches, and cross-field validation (one field referencing another). Notice the validator on the confirm password field reads from the password controller.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: SignupFormPage()));
class SignupFormPage extends StatefulWidget {
const SignupFormPage({super.key});
@override
State<SignupFormPage> createState() => _SignupFormPageState();
}
class _SignupFormPageState extends State<SignupFormPage> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmController = TextEditingController();
bool _obscurePassword = true;
bool _submitted = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmController.dispose();
super.dispose();
}
void _signup() {
if (_formKey.currentState!.validate()) {
setState(() => _submitted = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Account created for ${_emailController.text.trim()}!'),
backgroundColor: Colors.green,
),
);
}
}
// Reusable helper to build consistent-looking fields
Widget _buildField({
required TextEditingController controller,
required String label,
required String hint,
required IconData icon,
required String? Function(String?) validator,
TextInputType keyboardType = TextInputType.text,
bool obscure = false,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscure,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
prefixIcon: Icon(icon),
),
validator: validator,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create Account')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Create your account',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
// Full name
_buildField(
controller: _nameController,
label: 'Full name',
hint: 'Priya Sharma',
icon: Icons.person_outline,
validator: (value) {
if (value == null || value.trim().isEmpty) return 'Name is required';
if (value.trim().length < 2) return 'Name is too short';
return null;
},
),
const SizedBox(height: 16),
// Email
_buildField(
controller: _emailController,
label: 'Email address',
hint: '[email protected]',
icon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.trim().isEmpty) return 'Email is required';
if (!value.contains('@') || !value.contains('.')) {
return 'Enter a valid email address';
}
return null;
},
),
const SizedBox(height: 16),
// Password
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
hintText: 'At least 8 characters',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility
: Icons.visibility_off),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: (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;
},
),
const SizedBox(height: 16),
// Confirm password — reads from _passwordController to compare
TextFormField(
controller: _confirmController,
obscureText: _obscurePassword,
decoration: const InputDecoration(
labelText: 'Confirm password',
hintText: 'Re-enter your password',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please confirm your password';
}
// Cross-field validation: compare with password field
if (value != _passwordController.text) {
return 'Passwords do not match';
}
return null;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _submitted ? null : _signup, // disable after submit
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: Text(
_submitted ? 'Account Created ✓' : 'Create Account',
style: const TextStyle(fontSize: 16),
),
),
],
),
),
),
);
}
}
_passwordController.text directly. This is normal — validators are closures and can access anything in scope. Cross-field validation is this simple in Flutter; no special API needed.
6. Validating While Typing with autovalidateMode
By default, validators only fire when you call validate(). If you want errors to appear as the user types — common in signup screens — use autovalidateMode. The best beginner-friendly option is AutovalidateMode.onUserInteraction, which waits until the user has touched the field before starting validation.
// Option A: Set on individual fields (recommended for most cases)
TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
// ↑ Validation starts running only AFTER the user first interacts with this field.
// Much better UX than showing errors before the user has typed anything.
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) return 'Username is required';
if (value.length < 4) return 'At least 4 characters needed';
if (value.contains(' ')) return 'No spaces allowed';
return null;
},
)
// Option B: Set on the entire Form (all fields validate as user types)
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
TextFormField(/* ... */),
TextFormField(/* ... */),
],
),
)
autovalidateMode value |
When validators fire | Best for |
|---|---|---|
disabled (default) |
Only on validate() call |
Login forms (validate on submit) |
onUserInteraction |
After first interaction with that field | Signup forms (live feedback per field) |
always |
Immediately on every rebuild | Avoid — shows errors before user types anything |
7. Controller Tricks: Prefill, Clear, and Listen
A common real-world scenario: the user opens an “Edit Profile” screen and the fields are pre-populated with their existing data. You can prefill a field in initState():
class EditProfilePage extends StatefulWidget {
// Existing user data passed in from the previous screen
final String existingName;
final String existingEmail;
const EditProfilePage({
super.key,
required this.existingName,
required this.existingEmail,
});
@override
State<EditProfilePage> createState() => _EditProfilePageState();
}
class _EditProfilePageState extends State<EditProfilePage> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _emailController;
@override
void initState() {
super.initState();
// Prefill controllers with the data passed from the previous screen
_nameController = TextEditingController(text: widget.existingName);
_emailController = TextEditingController(text: widget.existingEmail);
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
void _saveChanges() {
if (_formKey.currentState!.validate()) {
// In a real app: send updated values to your database
final updatedName = _nameController.text.trim();
final updatedEmail = _emailController.text.trim();
Navigator.pop(context); // go back after saving
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Edit Profile'),
actions: [
TextButton(
onPressed: _saveChanges,
child: const Text('Save', style: TextStyle(color: Colors.white)),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Display name',
border: OutlineInputBorder(),
),
validator: (value) =>
(value == null || value.trim().isEmpty) ? 'Name is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) return 'Email is required';
if (!value.contains('@')) return 'Enter a valid email';
return null;
},
),
],
),
),
),
);
}
}
8. InputDecoration: Making Fields Look Good
Both TextField and TextFormField share the same InputDecoration class for styling. Here’s a reference of the properties beginners use most:
TextFormField(
decoration: InputDecoration(
// Text shown above the field when focused or filled
labelText: 'Email address',
// Placeholder text shown when the field is empty and unfocused
hintText: '[email protected]',
// Helper text shown below the field (disappears when error shows)
helperText: 'We will never share your email',
// Static error text — usually leave null and let validator handle it
// errorText: 'Something went wrong',
// Icon inside the field on the left
prefixIcon: const Icon(Icons.email_outlined),
// Widget on the right side of the field (icon buttons, clear buttons)
suffixIcon: const Icon(Icons.check_circle, color: Colors.green),
// Short text prefix inside the field (e.g. for currency)
// prefixText: '$',
// Border style — OutlineInputBorder gives the full rectangular border
border: const OutlineInputBorder(),
// Customise the focused border colour
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.amber.shade700, width: 2),
),
// Fill the background of the field
filled: true,
fillColor: Colors.grey.shade50,
),
)
| Property | What it shows | Goes away when |
|---|---|---|
labelText |
Floating label above field | Never — floats up when focused |
hintText |
Placeholder inside field | When user starts typing |
helperText |
Info text below field | Replaced by error text when invalid |
errorText |
Red error below field | When set to null |
prefixIcon |
Icon on left inside field | Never |
suffixIcon |
Icon/button on right inside field | Never (or conditionally) |
9. Common Beginner Mistakes
Mistake 1: Using TextField and looking for validator
// ❌ WRONG — TextField has no validator property
TextField(
validator: (value) { ... }, // compile error: no such property
)
// ✅ CORRECT — TextFormField inside a Form
Form(
key: _formKey,
child: TextFormField(
validator: (value) { ... }, // works ✅
),
)
Mistake 2: Forgetting to assign key: to Form
// ❌ WRONG — Form has no key, so _formKey.currentState is null
Form(
// key: _formKey, ← forgotten
child: TextFormField(/* ... */),
)
// Later:
_formKey.currentState!.validate(); // throws: Null check on null value
// ✅ CORRECT
Form(
key: _formKey, // ← always attach your key
child: TextFormField(/* ... */),
)
Mistake 3: Returning false instead of a String from validator
// ❌ WRONG — validator must return String? not bool
validator: (value) {
if (value!.isEmpty) return false; // compile error: wrong return type
}
// ✅ CORRECT — return a String message or null
validator: (value) {
if (value == null || value.isEmpty) return 'This field is required';
return null; // null = valid
}
Mistake 4: Forgetting to dispose controllers
// ❌ WRONG — controller is never cleaned up, causes a memory leak
class _MyFormState extends State<MyForm> {
final _controller = TextEditingController();
// No dispose() override
}
// ✅ CORRECT — always override dispose and call controller.dispose()
class _MyFormState extends State<MyForm> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose(); // ← frees resources when widget leaves the tree
super.dispose();
}
}
Mistake 5: Calling validate() before the Form is built
// ❌ WRONG — called in initState before the Form widget exists
@override
void initState() {
super.initState();
_formKey.currentState!.validate(); // throws: currentState is null
}
// ✅ CORRECT — validate() is always called in response to user actions
// (button press, gesture) AFTER the form has been built
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// safe here — form exists and user triggered this
}
},
child: const Text('Submit'),
)
10. Interview Q&A
Q1: What is the difference between TextField and TextFormField?
TextField is the basic text input widget — it captures text and exposes it via a controller or onChanged, but has no built-in validation support. TextFormField is a wrapper around TextField that integrates with the Form widget, adding a validator callback that can display error messages and participate in the form’s validate() flow.
Q2: What does validator return, and what does each return value mean?
validator function returns String?. If the field value is invalid, return a non-null String — that string is displayed as the error message below the field. If the value is valid, return null. Returning null means valid; returning any string means invalid.
Q3: Why do we use GlobalKey<FormState>?
Form widget holds state (validity of all its child fields), but that state lives inside the widget tree. A GlobalKey<FormState> gives you a stable handle to access that state from anywhere in your code — typically to call validate() when a submit button is pressed, which triggers all field validators at once and returns true only if every field passes.
Q4: What does validate() do exactly?
TextFormField inside the Form, calls each field’s validator with the current value, and shows the returned error string below each field that fails. It returns true if every validator returned null (all fields valid), and false if any returned a non-null string.
Q5: When should you use TextEditingController?
TextEditingController when you need to read the field’s value after the user types (e.g. on submit), prefill the field with existing data, clear the field programmatically, or listen to every text change. Always create it in the widget’s state, and always call dispose() on it in the dispose() method to prevent memory leaks.
Q6: How do you validate while typing instead of only on submit?
autovalidateMode: AutovalidateMode.onUserInteraction on the TextFormField or on the Form widget. This triggers the validator after the user first interacts with the field, giving live feedback as they type without showing errors before they’ve touched anything. Avoid AutovalidateMode.always — it shows errors immediately on load before the user has had a chance to type.
See Forms in a Real App
Forms and controllers are used throughout the notes app series — the note editor uses a TextEditingController to read the title and body before saving. See the full pattern in context:
| Article | Input concepts in use |
|---|---|
| Notes App Part 1: Project Setup & Core UI | TextEditingController for title + body, reading values on save, dispose() |
| Notes App Part 2: Local Persistence & Polish | Controllers with async state, prefilling editor from existing note data |
| Flutter Widgets: Stateless vs Stateful | StatefulWidget and setState() — required for all form examples |

Pingback: Hot Reload vs Hot Restart in Flutter (With When to Use Which)