Complete Form: Submission, Error Display & UX Polish
Complete Form: Submission, Error Display & UX Polish
In this final lesson of the Forms tutorial, we bring together every concept covered so far — TextFormField, FormKey, validators, controllers, and focus management — into a fully polished multi-field registration form. A production-quality form must do more than validate: it must show inline errors at the right moment, give visual feedback during an async submission, confirm success to the user, and reset cleanly so the form is ready again. Mastering these patterns will let you build forms that feel professional and trustworthy.
The Four Pillars of a Complete Form
- Auto-validation mode — switch from
AutovalidateMode.disabledtoAutovalidateMode.onUserInteractionafter the first submit attempt, so errors appear as the user corrects them. - Async submission with loading state — disable the button and show a spinner while the network call is in flight to prevent double-submits.
- Success feedback — display a
SnackBaror navigate away to confirm the action completed. - Form reset — call
_formKey.currentState!.reset()and clear controllers after success so the form returns to its initial state.
Building the Registration Form Widget
The widget below is a complete, self-contained registration form with name, email, and password fields. Study the state variables: _autovalidateMode starts disabled and is promoted on the first invalid submit; _isLoading drives the button state; and the GlobalKey<FormState> provides access to validation and reset.
Complete Registration Form
import 'package:flutter/material.dart';
class RegistrationForm extends StatefulWidget {
const RegistrationForm({super.key});
@override
State<RegistrationForm> createState() => _RegistrationFormState();
}
class _RegistrationFormState extends State<RegistrationForm> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
AutovalidateMode _autovalidateMode = AutovalidateMode.disabled;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
// Simulates a network call (e.g. POST /register)
Future<void> _submitForm() async {
// First attempt: enable per-field validation feedback
if (!_formKey.currentState!.validate()) {
setState(() {
_autovalidateMode = AutovalidateMode.onUserInteraction;
});
return;
}
setState(() => _isLoading = true);
try {
// Replace with your real API call
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
// Success feedback
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Registration successful! Welcome aboard.'),
backgroundColor: Colors.green,
),
);
// Reset the form and all controllers
_formKey.currentState!.reset();
_nameController.clear();
_emailController.clear();
_passwordController.clear();
setState(() {
_autovalidateMode = AutovalidateMode.disabled;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@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,
autovalidateMode: _autovalidateMode,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Full Name',
prefixIcon: Icon(Icons.person_outline),
),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your full name.';
}
if (value.trim().length < 2) {
return 'Name must be at least 2 characters.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email.';
}
final emailRegex = RegExp(
r'^[\w\-.]+@([\w\-]+\.)+[\w]{2,}$',
);
if (!emailRegex.hasMatch(value)) {
return 'Please enter a valid email address.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() => _obscurePassword = !_obscurePassword);
},
),
),
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submitForm(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a password.';
}
if (value.length < 8) {
return 'Password must be at least 8 characters.';
}
if (!RegExp(r'[A-Z]').hasMatch(value)) {
return 'Include at least one uppercase letter.';
}
return null;
},
),
const SizedBox(height: 32),
FilledButton(
onPressed: _isLoading ? null : _submitForm,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Create Account'),
),
],
),
),
),
);
}
}
mounted before calling setState() or ScaffoldMessenger inside an async method. If the user navigates away while the network call is in flight, the widget is disposed and accessing its context would throw an error.AutovalidateMode in Depth
Flutter provides three modes via the AutovalidateMode enum:
AutovalidateMode.disabled— validators only run when you callvalidate()explicitly. Best as the initial state.AutovalidateMode.onUserInteraction— validators re-run after every change, but only on fields the user has touched. This is the ideal post-first-submit mode: corrected fields get instant feedback without alarming fields the user has not yet visited.AutovalidateMode.always— validators run on every rebuild even for untouched fields. Rarely appropriate for registration forms.
disabled, flip to onUserInteraction on the first failed submit — is considered best practice in production Flutter apps. It avoids bombarding the user with error messages before they have had a chance to fill in anything.The Loading Button Pattern
Passing null to a button's onPressed disables it visually and functionally — Flutter renders it in its disabled style automatically. Combine this with a compact CircularProgressIndicator inside the button to communicate that work is happening:
Loading Button State
// _isLoading controls both the disabled state and the child widget
FilledButton(
onPressed: _isLoading ? null : _submitForm,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Create Account'),
)
_formKey.currentState!.reset() alone after a successful submission. That resets the form validation state but does NOT clear the TextEditingController values. You must call controller.clear() (or controller.text = '') on each controller separately, or the text fields will still show the old text while the form thinks it is empty.UX Polish Checklist
- Use
textInputAction: TextInputAction.nexton all fields except the last, so the keyboard "Next" button moves focus automatically. - Set
keyboardTypeappropriately (TextInputType.emailAddress,TextInputType.visiblePassword) so the correct keyboard appears. - Add a
prefixIconto each field for quick visual scanning. - Provide a show/hide password toggle via
suffixIcon+obscureText. - Connect the last field's
onFieldSubmittedcallback to_submitForm()so pressing the keyboard "Done" key submits the form. - Wrap the form in
SingleChildScrollViewso it survives the keyboard pushing content up on smaller devices.
Summary
A complete form in Flutter combines: a GlobalKey<FormState> for programmatic control; TextEditingControllers for reading and resetting values; a two-phase AutovalidateMode for ergonomic error display; a _isLoading flag for async-safe submission; a mounted guard for async safety; and a thorough reset sequence (both form.reset() and controller.clear()) after success. Apply the UX polish checklist — correct keyboard types, focus chain, and a show/hide password toggle — and your form will be indistinguishable from those in top-tier production apps.