Forms and User Input in Flutter: Form, TextFormField, Validation, Focus and the Keyboard
In Flutter a form is held together by Form and a GlobalKey<FormState>; a field is valid when its validator returns null, and the server's ProblemDetails errors are written to the matching field with forceErrorText. Using the sign-in and delivery address forms, we show with real test output how to choose an AutovalidateMode, focus the first invalid field with FocusNode, where input formatters stop, how the body shrinks when the keyboard opens, and the Angular Reactive Forms counterparts.
Version note: The examples in this post were run with Flutter 3.47.8 (Dart 3.13.7) and material_ui 1.11.0. All output comes from flutter test runs; keyboard, emulator and device behavior was simulated in the test environment, and nothing was run on a real device.
Guess First
The delivery address form of Bookstore, the online bookstore used as the shared example across this site's series, has a postal code field. We attached two input formatters to it: one that allows only digits, and one that limits the length to 5.
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(5),
],
The user types (or pastes) 34a7x1 into the field. What shows up in the field: 34a7x, 34710, 3471, or nothing plus an error message? And when the form is submitted, which field shows the error? We will see both in a test.
Form, GlobalKey<FormState> and TextFormField
A form in Flutter is built from three pieces: a Form that holds the fields together, a TextFormField for each field, and a GlobalKey<FormState> to reach the form's state from outside. In the sixth post we said GlobalKey is for reaching a state from elsewhere in the tree; this is where it is used. The state of the sign-in screen:
class _SignInScreenState extends State<SignInScreen> {
final _formKey = GlobalKey<FormState>();
final _email = TextEditingController();
final _password = TextEditingController();
var _obscure = true;
var _submitting = false;
String? _error;
@override
void dispose() {
_email.dispose();
_password.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_submitting) return;
if (!_formKey.currentState!.validate()) return;
setState(() {
_submitting = true;
_error = null;
});
final error = await widget.onSignIn(_email.text.trim(), _password.text);
if (!mounted) return;
setState(() {
_submitting = false;
_error = error;
});
}
validate() runs the validator of every field in the form, writes the errors under the fields and returns true if all are valid. _submitting keeps a double tap from sending two requests and disables the button. The mounted check after the await is the same rule as in the sixth post.
TextEditingController is an object that holds the field's text and notifies its listeners. Every controller you create in a State must be closed in dispose; otherwise it stays in memory after the screen is closed. One of the form's fields:
TextFormField(
controller: _password,
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
tooltip: _obscure ? 'Show password' : 'Hide password',
icon: Icon(
_obscure ? Icons.visibility : Icons.visibility_off,
),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
obscureText: _obscure,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
validator: Validators.password,
onFieldSubmitted: (_) => _submit(),
),
obscureText hides the password, and the eye icon toggles it. autofillHints, together with the AutofillGroup around the fields, tells the operating system's password manager that these fields are an email and a password; the user can fill in saved details with one tap. onFieldSubmitted submits the form when the "done" key on the keyboard is pressed.
Validators and AutovalidateMode
A validator is a function that takes the field's value and returns null if it is valid, or the error text if not. To avoid writing the same rules again in every form, we collected them in one place:
/// Field validators for TextFormField: null means valid, a string is the error.
abstract final class Validators {
static final _email = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
static final _postalCode = RegExp(r'^\d{5}$');
static String? required(String? value) =>
value == null || value.trim().isEmpty ? 'This field is required' : null;
static String? email(String? value) =>
required(value) ??
(_email.hasMatch(value!.trim()) ? null : 'Enter a valid email address');
static String? password(String? value) =>
required(value) ??
(value!.length >= 8 ? null : 'Use at least 8 characters');
/// Turkish postal codes have five digits.
static String? postalCode(String? value) =>
required(value) ??
(_postalCode.hasMatch(value!.trim()) ? null : 'Postal code has 5 digits');
Because they are pure functions, they are tested with plain unit tests, without widgets. We deliberately kept the email regex loose: the client only catches obvious mistakes, and the server verifies that the address actually exists.
autovalidateMode decides when validation happens. In Flutter 3.47 there are five values: disabled (only when validate() is called), always, onUserInteraction (after every change), onUnfocus (when the field loses focus) and onUserInteractionIfError (re-checks only a field that has an error, as it changes). Our forms use the last one: while typing, the user does not see a red warning for an email they have not finished yet, but after submitting, the warning disappears the moment they fix the error. In a test we submitted the empty form, then fixed the fields:
after empty submit: 2 errors
typed "ada@": 1 email error
fixed: 0 required errors left
Focus, Keyboard Type and Input Formatters
Most of filling in a form on mobile happens on the keyboard. keyboardType chooses which keyboard opens (an @ key for emailAddress, the digit keyboard for number, the keypad for phone). textInputAction sets the bottom-right key of the keyboard: next moves to the next field, done closes the keyboard. In a test we simulated the keyboard's "next" key while in the email field:
before next: email focused true
after next: password focused true
We wrote no code for this: on the next action, TextFormField moves to the next field in the FocusScope by itself. When you want to manage focus in code, FocusNode comes in. In the address form we kept a FocusNode and a GlobalKey<FormFieldState> for each field, and on submit we focused the first invalid field:
Widget _field(
AddressField field,
String label,
FormFieldValidator<String> validator, {
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
Iterable<String>? autofillHints,
TextInputAction action = TextInputAction.next,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextFormField(
key: _fieldKeys[field],
controller: _controllers[field],
focusNode: _focusNodes[field],
decoration: InputDecoration(labelText: label),
keyboardType: keyboardType,
inputFormatters: inputFormatters,
autofillHints: autofillHints,
textInputAction: action,
validator: validator,
// The server's message wins until the user edits the field
forceErrorText: _serverErrors[field.apiName],
onChanged: (_) {
if (_serverErrors.containsKey(field.apiName)) {
setState(() => _serverErrors.remove(field.apiName));
}
},
FocusNode is also a ChangeNotifier; like controllers, it has to be disposed. We ran the "Guess First" scenario: we filled in only the name field, typed 34a7x1 into the postal code and submitted:
postal code text after formatters: "3471"
focused: Address true, Full name false
The formatters run in order: digitsOnly dropped the letters and left 3471; it is shorter than five, so the length limit did nothing. A formatter corrects the input but does not validate it: four digits are still invalid, and the validator will catch that. Focus went to the first invalid field after the name, the address. In a long form, the user does not have to scroll to look for the error.
When the Keyboard Covers the Content
When the keyboard opens, it covers the bottom half of the screen. The resizeToAvoidBottomInset property of Scaffold is true by default: it reads the keyboard height from viewInsets in MediaQuery and gives the body that much less room. Because the body contains a SingleChildScrollView, the content can scroll, and the focused field is scrolled into the visible area. On a small 320 x 568 screen we simulated a 260-pixel keyboard and tapped the phone field at the bottom:
no keyboard: body max height 512.0, phone field bottom 400.0
keyboard 260: body max height 252.0, phone field bottom 277.0 (keyboard top 308)
When the keyboard opened, the body height dropped from 512 to 252. The bottom edge of the phone field was at 400, so it would have been behind the keyboard (which starts at 308); Flutter scrolled the field to 277. If either piece were missing, the field would stay under the keyboard: a Scaffold with resizeToAvoidBottomInset: false, or a Column that cannot scroll. The second one also brings back the overflow error from the earlier posts: a Column that does not fit into 252 pixels.
Mapping Server Errors to Fields
Validation on the client gives the user quick feedback, but the server has the last word: only the server knows rules such as "we do not deliver to this postal code" or "only 2 copies are in stock". ASP.NET Core returns ValidationProblemDetails for validation errors; we saw that response from a real API in the "Model Validation in ASP.NET Core: Data Annotations and FluentValidation" post of the ASP.NET Core Web API series. The keys in errors are property paths: ShippingAddress.PostalCode, Items[0].Quantity. Since we are not connected to the API yet in this post, we used a test input written by hand in the same shape; connecting to the real API comes in the API integration post. The small class that reads the response:
/// The field errors of an ASP.NET Core ValidationProblemDetails response:
/// {"title": "...", "status": 400, "errors": {"PostalCode": ["..."]}}
class ValidationProblem {
const ValidationProblem({required this.title, required this.errors});
final String title;
final Map<String, List<String>> errors;
factory ValidationProblem.fromJson(Map<String, Object?> json) {
final errors = <String, List<String>>{};
if (json case {'errors': Map<String, Object?> raw}) {
for (final MapEntry(:key, :value) in raw.entries) {
if (value case List<Object?> messages) {
errors[key] = [for (final m in messages) '$m'];
}
}
}
return ValidationProblem(
title: json['title'] as String? ?? 'The request is not valid.',
errors: errors,
);
}
/// The first message for each field whose key starts with [prefix],
/// with the prefix removed ("ShippingAddress.City" becomes "City").
Map<String, String> fieldErrors({String prefix = ''}) => {
for (final MapEntry(:key, :value) in errors.entries)
if (key.startsWith(prefix) && value.isNotEmpty)
key.substring(prefix.length): value.first,
};
}
The pattern matching from the fourth post helps here to read JSON safely: if errors is missing or not of the expected type, the case does not match and we are left with an empty map instead of an exception. In a test we decoded a response containing two address errors and one cart error:
PostalCode -> We do not deliver to this postal code.
City -> The field City must be at most 40 characters.
To put an error on a field we used the forceErrorText property of TextFormField: when it is set, the validator is not called at all and the field shows as invalid with this message. When the user changes the field, onChanged removes the message and control goes back to the validator. An error that matches no field (Items[0].Quantity) is shown above the form so it does not get lost. The test shows what happens when the server response arrives and what changes when the user fixes the postal code:
postal code error: 1, form error: 1, postal code focused: true
after editing: 0 postal code error
Compared with Angular Reactive Forms
If you have read the "Angular Reactive Forms: FormGroup, FormBuilder, FormArray and Validators" post of the Angular series, most of the concepts are familiar. The mapping:
| Angular Reactive Forms | Flutter |
|---|---|
FormGroup | Form + GlobalKey<FormState> |
FormControl | TextFormField + TextEditingController |
Validators.required, your own ValidatorFn | a String? Function(String?) validator |
form.valid / markAllAsTouched() | formKey.currentState!.validate() |
updateOn: 'blur' | AutovalidateMode.onUnfocus |
control.setErrors({ server: ... }) | forceErrorText |
valueChanges | onChanged or a controller listener |
The biggest difference is that Flutter does not collect a form's values into a single model object. In Angular, form.value gives you a ready object; in Flutter you read the values from the controllers and build the model (DeliveryAddress) yourself.
In Production
Write the validation rule on both sides, but treat the server as authoritative. The client rule is only quick feedback; an old version of the app or a request that goes straight to the API bypasses the client. That is why the code that maps the server's messages to fields matters more than the client rules.
Try the form with a real keyboard. The test environment simulates the keyboard height, but it does not show the suggestion bar above the keyboard, the password suggestion on iOS or the different keyboard apps on Android. No real device testing was done for this post.
Give error messages in the user's language. If the server returns messages localized by Accept-Language (with the locale from the ninth post), the client should send the same language; otherwise an English server message shows up in the Turkish UI.
Common Pitfalls
The first pitfall is creating a TextEditingController inside build. Every rebuild creates a new controller, the text the user typed is lost, and the old controller is never disposed. Controllers are fields of the State and are closed in dispose.
The second pitfall is using a formatter instead of a validator. LengthLimitingTextInputFormatter(5) blocks more than five digits but does not block incomplete input; the 3471 from "Guess First" is an example.
The third pitfall is showing a server error with InputDecoration.errorText. That changes only the appearance; the form still counts as valid, and validate() does not clear the error. forceErrorText also puts the field's state into error.
The fourth pitfall is swallowing a server error that cannot be mapped. When a key with no counterpart in the form arrives, the user sees no message and thinks the button does not work; always show unmapped errors above the form.
A Small Experiment
★☆☆ Change the sign-in form's autovalidateMode to onUserInteraction and run the test. Does the "typed ada@" line change? What is the difference for the user?
★★☆ Add a CheckboxListTile saying "Billing address is the same as the delivery address" to the address form; when it is unchecked, open a second address section and map ValidationProblem to it with the BillingAddress. prefix.
★★★ Add a method to ValidationProblem that turns indexed keys such as Items[0].Quantity into an (index: 0, field: 'Quantity') record, and plan how to show the error under the matching row on the cart screen. Write its unit test.
In the next post we take state out of the widgets: MVVM per the official architecture guide, ChangeNotifier and ListenableBuilder, dependency injection with provider, the Command pattern, and a CartViewModel for the cart screen.