Server errors and read-only

Pushing server errors

Apply a 422 response field by field with setError, after the await, and let the next edit clear it.

Four methods cover everything a form has to do to a field from the outside:

field.setError(MyError.emailTaken); // push an error in, e.g. from a 422 response
field.markReadOnly();               // setValue becomes a no-op unless force: true
field.clearErrors();                // forget everything, including the async verdict
field.reset();                      // back to initialValue; errors and verdict gone

Save the form below with an email ending in @taken.com and the fake server rejects it; save anything else and the form locks itself.

profile_form.dartidle
Source2 files
profile_form_controller.dart
enum ProfileError { required, invalid, emailTaken, nameTaken }

class ProfileFormController extends AdvancedFormController {
  ProfileFormController() : super(validationMode: ValidationMode.onUnfocus) {
    registerFields([displayName, email]);
  }

  final displayName = AdvancedTextFieldController(
    initialValue: 'Ada',
    validator: filled(ProfileError.required),
  );

  final email = AdvancedTextFieldController(
    initialValue: 'ada@example.com',
    validator: filled(ProfileError.required) & isEmail(ProfileError.invalid),
  );

  /// Validates, then applies the server's verdict field by field.
  Future<bool> save() async {
    if (!await validate()) {
      return false;
    }

    final rejected = await _fakeSave(email.fieldValue, displayName.fieldValue);

    // After the await, never before: validate() re-runs the sync validators
    // and would overwrite a code pushed earlier. `null` clears, so accepted
    // fields end up cleanly valid.
    email.setError(rejected['email']);
    displayName.setError(rejected['displayName']);

    if (rejected.values.any((error) => error != null)) {
      return false;
    }
    markReadOnly(); // saved — freeze the whole form until "Edit"
    return true;
  }

  Future<Map<String, ProfileError?>> _fakeSave(
    String email,
    String name,
  ) async {
    await Future<void>.delayed(const Duration(milliseconds: 500));
    return {
      'email': email.endsWith('@taken.com') ? ProfileError.emailTaken : null,
      'displayName': name.toLowerCase() == 'admin'
          ? ProfileError.nameTaken
          : null,
    };
  }
}

/// Pragmatic, not RFC 5322: one `@`, no whitespace, a dot in the domain.
final emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');

Validator<String?, E> isEmail<E extends Object>(E error) =>
    (value) => value != null && emailPattern.hasMatch(value) ? null : error;

String describe(ProfileError error) => switch (error) {
  ProfileError.required => 'Required',
  ProfileError.invalid => 'Not an email address',
  ProfileError.emailTaken => 'The server says this email is taken',
  ProfileError.nameTaken => 'The server says this name is taken',
};
DocsActions, DocsHint, DocsSubmitButton and DocsTextField are shorthands these docs define, not part of the package. See Rendering fields for the widget code an app writes.

How setError behaves

setError(E?) writes the field's validationError — the same slot the sync validator writes — and the status follows. Three consequences shape the pattern above:

  • Push after the await, never before. validate() and anything else that re-runs the sync validator — an edit with the gate open, subscribeToFields, validateAll — overwrites a pushed code with whatever the validator returns.
  • That is also why the error clears itself. The next edit, or the next submit, replaces the pushed code with the validator's verdict — no "clear server errors" step.
  • setError(null) is what makes "apply the response to every field" work. Fields the server accepted end up valid with nothing to show, rather than invalid with nothing to show.

setError leaves asyncError alone. A code an async check recorded survives it — use clearErrors() when you mean "forget everything, including the async answer". Pushing an error aborts a live async round, so the round cannot erase what you just pushed; the settled verdict is kept, because the value did not change.

A map over fields of mixed value types is Map<String, AdvancedFieldController<dynamic, MyError>> — spell E out, inference widens it to Object.

Next

On this page