Introduction
Forms are central to mobile applications. Registration, checkout, profile editing, and settings screens all rely on collecting user input.
A form may start with a few fields and quickly grow to include validation, network requests, loading indicators, and server-side errors. A clear structure helps prevent these responsibilities from becoming tangled.
Use Form and TextFormField
Flutter provides built-in form components suitable for many screens.
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 _emailController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
String? _validateEmail(String? value) {
final email = value?.trim() ?? '';
if (email.isEmpty) {
return 'Email is required';
}
if (!email.contains('@')) {
return 'Enter a valid email address';
}
return null;
}
void _submit() {
if (!_formKey.currentState!.validate()) {
return;
}
final email = _emailController.text.trim();
// Send validated input to the application layer.
debugPrint('Submitting registration for $email');
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'Email address',
),
validator: _validateEmail,
),
const SizedBox(height: 16),
FilledButton(
onPressed: _submit,
child: const Text('Create account'),
),
],
),
);
}
}
The email validator is intentionally simple. Real applications should use an appropriate validation policy and always enforce important business rules on the server.
Keep Validation Focused
Validation messages should explain what the user needs to correct.
Examples include:
- “Email is required.”
- “Password must contain at least 12 characters.”
- “Choose a delivery address.”
Avoid vague messages such as “Invalid input.” Also avoid rejecting legitimate user input without a real business reason.
Some validation can happen locally. Other checks, such as username availability, require a server request and may become outdated before submission.
Separate Validation From Submission
Local validation checks basic input requirements. Server-side validation enforces the authoritative business rules.
A production form should also represent its submission state clearly:
- Idle.
- Submitting.
- Successful.
- Failed.
Disable duplicate submissions while a request is running, but preserve the user’s input if the request fails.
Handle Asynchronous Submission Safely
An asynchronous operation may finish after a widget has been disposed. Check that the state remains mounted before using its context or updating its UI.
Future<void> submitForm() async {
setState(() {
_isSubmitting = true;
});
try {
await repository.register(
email: _emailController.text.trim(),
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Registration successful'),
),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Registration failed. Please try again.',
),
),
);
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
}
}
This fragment assumes that _isSubmitting and repository are defined in the surrounding state class. Production code should map known server errors to useful messages and log underlying failures appropriately without exposing internal details.
Make Forms Accessible
Use meaningful labels, suitable keyboard types, autofill hints, and visible error messages. Ensure controls remain usable when users increase system font sizes or zoom the interface.
For long forms, group related fields and preserve input when navigating between sections. Placeholder text should not be the only label because it disappears as the user types.
Test the Form
Useful tests verify that empty fields show errors, valid values pass validation, duplicate submissions are prevented, server failures are communicated clearly, and successful submissions produce the expected result.
Use a fake repository in widget tests so that testing does not depend on a live backend.
Conclusion
Maintainable Flutter forms combine built-in validation, explicit submission states, accessibility, and separation between the UI and business logic.
Start with Form and TextFormField for straightforward screens. Introduce shared field widgets or a dedicated form-state solution only when repeated patterns or complexity justify them.