skip to content

In a Flutter vehicle-registration form, how do you show a server's "plate already registered" error on a TextFormField when validators are synchronous?

level: seniorimportance: should knowfreq 30%

answer

  1. validator is String? Function(T?)
  2. some modes validate on every build
  3. forceErrorText, since 3.24
  4. clear it on the next edit
  5. mounted check after the await

basics

~20 s

Validate locally, submit, and when the server rejects the plate, store its message in State and pass it as the field's forceErrorText (Flutter 3.24+); clear it when the user edits. Never put the network call inside a validator.

solid answer

~40 s

A validator is `String? Function(String?)`: synchronous, and under `always` or `onUserInteraction` it runs on rebuilds, so it cannot own a network check. The submit handler runs `validate()`, awaits the API, checks `mounted`, and on a conflict stores the server's message in a `State` field passed as `forceErrorText`. While that is non-null the field shows it and `validate()` fails the field without calling the validator, so the same taken plate cannot be resubmitted. In `onChanged` I set it back to `null` so the user's next edit clears it. Setting `InputDecoration.errorText` instead only displays text: `validate()` ignores it. For long forms, `validateGranularly()` returns the invalid fields so I can scroll to the first.

code

dart · 63 lines
dart
import 'package:flutter/material.dart';

class RegistrationForm extends StatefulWidget {
  const RegistrationForm({super.key, required this.register});

  /// Completes with null on success, or with the server's message for the plate.
  final Future<String?> Function(String plate) register;

  @override
  State<RegistrationForm> createState() => _RegistrationFormState();
}

class _RegistrationFormState extends State<RegistrationForm> {
  final _formKey = GlobalKey<FormState>();
  final _plate = TextEditingController();
  String? _plateServerError;
  bool _submitting = false;

  @override
  void dispose() {
    _plate.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    if (!_formKey.currentState!.validate()) return;
    setState(() => _submitting = true);
    try {
      final serverError = await widget.register(_plate.text.trim());
      if (!mounted) return;
      setState(() => _plateServerError = serverError);
    } finally {
      if (mounted) setState(() => _submitting = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _plate,
            forceErrorText: _plateServerError,
            decoration: const InputDecoration(labelText: 'Plate number'),
            validator: (value) =>
                (value == null || value.trim().isEmpty) ? 'Enter the plate number' : null,
            onChanged: (_) {
              if (_plateServerError != null) {
                setState(() => _plateServerError = null);
              }
            },
          ),
          FilledButton(
            onPressed: _submitting ? null : _submit,
            child: const Text('Register'),
          ),
        ],
      ),
    );
  }
}

go deeper

for a junior

Know that a validator is synchronous and returns a message or null, so a server's answer must reach the field some other way after the request finishes.

for a middle

Explain forceErrorText: it shows immediately, makes validate() fail without calling the validator, and stays until your state passes null again.

for a senior

Build the whole submit flow: local validation, a guarded await with a mounted check, server errors mapped to fields, double-submit protection, and per-step validation in multi-step forms.

for a principal

Define how server-side validation errors are shaped and mapped to fields across the app, so every form reports conflicts the same way.

## Why a validator cannot do this `TextFormField.validator` has the type **`FormFieldValidator<String>`**, which is `String? Function(String? value)`. That rules out server checks for three reasons: - it is **synchronous**; a `Future` is not a `String?`, so an `async` validator does not compile; - depending on the `AutovalidateMode`, it runs during **builds**, possibly on every rebuild, so a request inside it would fire again and again; - its job is to judge the value the user sees *now*, while "already registered" is a fact about the server that only a submit attempt reveals. So the flow splits: local rules live in the validator, and the server's verdict is fed back into the field after the request completes. ## The forceErrorText pattern `FormField`, and so `TextFormField`, takes **`forceErrorText`** (Flutter 3.24+). The flow for a vehicle registration: 1. The user taps Register; the handler calls `_formKey.currentState!.validate()` and stops if local rules fail. 2. It awaits the API call with the trimmed plate. 3. After the `await` it checks **`mounted`**, because the screen may have been closed while the request was in flight. 4. On a conflict it calls `setState` to store the server's message in `_plateServerError`, which the field receives as `forceErrorText`. 5. The field's `onChanged` sets `_plateServerError` back to `null`, so the message disappears as soon as the user edits the plate. ## What forceErrorText does inside FormFieldState The behaviour, read from `FormFieldState` in the 3.47 source: - In `initState`, the field's error starts as `forceErrorText`, so a non-null value shows at once. - In `didUpdateWidget`, a changed `forceErrorText` replaces the current error; going back to `null` clears it. - In `validate()`, a non-null `forceErrorText` **becomes** the error and the validator is **not called**, so `validate()` returns `false` for that field. - `isValid` is `false` whenever `forceErrorText` is set. - A user edit does **not** clear it; your code does, by rebuilding with `null`. ## Alternatives and why they fall short | Approach | Shown on screen | Counted by `validate()` | |---|---|---| | `forceErrorText: _serverError` | yes | yes, the field fails | | Validator returns a `State` field holding the server message | only after the next validation | yes, but you must call `validate()` again | | `InputDecoration(errorText: _serverError)` | yes, while the field has no error of its own | **no**; the form can still pass | | `errorBuilder` (3.32+) | changes how any error is drawn | not a source of errors | The second row was the usual workaround before 3.24. `TextFormField` also asserts that `errorBuilder` and `decoration.errorText` are not both set. ## Long and multi-step forms - **Scrolling to the problem.** `FormState.validateGranularly()` returns the `Set<FormFieldState<Object?>>` of invalid fields; pass the first one's `context` to `Scrollable.ensureVisible` to bring it into view. - **Clearing everything.** `FormState.clearError()` (3.44+) removes every error but keeps the values. It does not remove the source of a `forceErrorText`: while your state still passes the message, the next validation of that field puts it back. - **Fields that are not built are not validated.** A `FormFieldState` registers with its `Form` when it builds and unregisters when it is deactivated. In a multi-step form inside a `PageView`, a step whose page has been disposed has no fields in the form, so `validate()` passes without checking them. Validate each step before leaving it, and keep the values in a model object rather than only in widgets. ## Several fields, one response A registration API often rejects more than one field at once, for example a plate that is taken and a postcode it does not recognise. Keep the server errors in one map owned by the `State`, keyed by field name, and pass each field its entry: 1. After the `await`, replace the whole map with the errors from the response (an empty map on success). 2. Give each `TextFormField` `forceErrorText: _serverErrors['plate']`, `_serverErrors['postcode']` and so on. 3. In each field's `onChanged`, remove only that field's key, so fixing the plate does not hide the postcode error. This keeps the server's verdict separate from local validation and makes it obvious which errors came from where. ## Production checklist - Disable the submit button while the request is pending, so a double tap does not send two registrations. - Map the server's error codes to fields in one place; show errors that belong to no field outside the form fields. - Keep the server message out of the validator, so autovalidation cannot erase or duplicate it.

  • In a PageView-based multi-step form under one Form, why can validate() pass while an earlier step's required field is empty?
    A `FormFieldState` registers with its `Form` when it builds and unregisters when it is deactivated. Once the earlier page has been disposed, its fields are no longer in the form's set, so `validate()` never runs their validators. Validate each step before advancing, and keep the entered values in a model that outlives the pages.
  • Why check mounted after awaiting the registration call?
    The user can leave the screen while the request is in flight. Calling `setState` on a disposed `State` is an error, reported as a `FlutterError` in debug builds, so the handler must stop when `mounted` is false. It also avoids using a `BuildContext` that is no longer in the tree, for example to show a snackbar.
  • Does FormState.clearError() remove an error that comes from forceErrorText?
    Only until that field is validated again. `clearError()` (Flutter 3.44+) nulls each field's current error and keeps the values, but `forceErrorText` is widget configuration: while your state still passes the message, the next `validate()` or autovalidation pass of that field restores it. Clear the state field itself.

saying these in an interview costs you the question

  • Make the validator async and await the server's plate check.
  • Setting InputDecoration.errorText makes validate() fail that field.
  • forceErrorText clears itself as soon as the user edits the field.
  • Calling setState after an await is safe without a mounted check.
  • One Form's validate() also checks fields on pages that are no longer built.