Models form fields as `formz` value objects and drives form state with a Bloc, including round-tripping Laravel 422 validation errors back to per-field UI errors. Use when implementing complex forms like login, OTP verification, address entry, profile editing, password reset, or any form with cross-field validation rules and server-side error surfaces. Prerequisite: `flutter-bloc-setup`, `flutter-bloc-feature-pattern`, and `flutter-bloc-async-api`. Use when this capability is needed.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Models form fields as `formz` value objects and drives form state with a Bloc, including round-tripping Laravel 422 validation errors back to per-field UI errors. Use when implementing complex forms like login, OTP verification, address entry, profile editing, password reset, or any form with cross-field validation rules and server-side error surfaces. Prerequisite: `flutter-bloc-setup`, `flutter-bloc-feature-pattern`, and `flutter-bloc-async-api`. Use when this capability is needed.
Forms with Bloc and Formz
Pushes per-field validation into typed value objects (FormzInput<TValue, TError>) so the Bloc only orchestrates: hold the fields, derive overall validity, submit, and route server-side validation errors back to the right field. Form fields become reusable types (EmailInput, PhoneInput) shared across screens. Submission status (FormzSubmissionStatus.inProgress / success / failure) is decoupled from field validity so a valid form can still fail to submit, and vice versa. Builds on flutter-bloc-async-api for the underlying request/response flow.
A naive form Bloc holds raw String fields and runs validation in the submit handler. That has three failure modes: (1) validation rules duplicate across forms that share a field (email validator written three times); (2) the View has to know whether to display an error (don't show "required" before the user touches the field); (3) server-side and client-side errors are tracked separately and drift apart.
formz solves all three by making each field a value object:
A FormzInput<TValue, TError> knows its current value, its validity, and whether it has been touched.
displayError returns the error only if the field is dirty โ the View can naively render it without an "if pure return null" check.
Validation rules live on the input class, not the Bloc. Reuse EmailInput in login, sign-up, profile-edit, and forgot-password.
FormzInput per field
A field is a sealed enum of failure modes plus a FormzInput subclass.
.pure(value) is for the initial state โ field is untouched, no error displayed.
.dirty(value) is for every subsequent update from user input โ the validator runs and displayError will surface its result.
For one-off validators (a field used only in one form), inline the class in the same file as the Bloc. For reusable validators (EmailInput, PhoneInput, PasswordInput), put them in lib/core/forms/ so every form Bloc can import them.
Form Bloc state shape
The state holds all fields as inputs, an overall submission status, and a slot for server-level failures that aren't per-field.
@freezed
class LoginFormState with _$LoginFormState {
const factory LoginFormState({
@Default(EmailInput.pure()) EmailInput email,
@Default(PasswordInput.pure()) PasswordInput password,
@Default(FormzSubmissionStatus.initial) FormzSubmissionStatus status,
String? serverError, // for 401, 500, network โ never for 422
}) = _LoginFormState;
const LoginFormState._();
bool get isValid => Formz.validate([email, password]);
}
A getter isValid derives from Formz.validate([...]) rather than being stored. The View consults state.isValid to decide whether the submit button is enabled โ no duplicated truth to keep in sync.
The initial state with all-pure() inputs is not valid โ pure inputs whose validator rejects empty strings are invalid-but-not-displayed. Submit is correctly disabled at start; displayError returns null until the field is dirty, so the user doesn't see "Email required" before typing.
Submission status is independent of validity. A form can be FormzSubmissionStatus.inProgress while isValid is true. After a 422, status flips to failure but isValid may still be true on the client (the server disagreed). Treat them as orthogonal axes.
formz exposes a FormzSubmissionStatusX extension with convenience getters: status.isInitial, status.isInProgress, status.isSuccess, status.isFailure, status.isCanceled, and the load-bearing status.isInProgressOrSuccess for "keep the submit button disabled while in flight and after success until navigation completes". Use them instead of raw status == FormzSubmissionStatus.inProgress comparisons.
Mapping Laravel 422 errors to fields
When flutter-bloc-async-api's repository returns Result.failure(Failure.validation(errors)) โ the Failure taxonomy defined in flutter-bloc-async-api โ the form Bloc splatters those errors back onto the corresponding inputs.
Laravel response:
{"message":"The given data was invalid.","errors":{"email":["The email has already been taken."],"phone":["The phone must be at least 10 digits."]}}
Each FormzInput subclass declares an extra final String? serverError field initialized by both .pure() and .dirty() constructors. The View prefers serverError over the client-side displayError. There is no extension, no withServerError(...) method, no freezed wrapper โ just a constructor parameter.
Stale-server-error invariant. The _onEmailChanged keystroke handler emits EmailInput.dirty(e.value) with no second argument, so serverError defaults to null and stale 422 messages clear automatically as the user types. The form re-renders without the obsolete error before the next submit even fires.
Workflow: Build a form with Bloc and formz
Task Progress
Step 1 โ Add the dep.flutter pub add formz.
Step 2 โ Decide which inputs are reusable. Inputs used in 2+ forms go in lib/core/forms/. One-offs stay in the feature folder.
Step 3 โ Write the FormzInput subclasses. Each has a pure() ctor, dirty() ctor, validation enum, and validator() override.
Step 4 โ Define the form state.@freezed class with one input per field, FormzSubmissionStatus, optional serverError, and a derived isValid getter using Formz.validate([...]).
Step 5 โ Define the events. One <Field>Changed(value) per field, plus Submitted and (optional) Reset.
Step 6 โ Implement field handlers. Each handler emits state.copyWith(<field>: <Input>.dirty(newValue)). Use transformer: sequential() for keystroke-driven fields (avoid concurrent() reordering).
Step 7 โ Implement the submit handler. Use transformer: droppable() so double-taps on submit are ignored. Guard with if (!state.isValid) return;. Emit inProgress, call the repository, pattern-match the Result.
Step 8 โ Map 422 to fields. In the failure branch, distribute ValidationFailure.errors map keys onto the matching inputs via <Input>.dirty(value) + a server-error attachment.
Step 9 โ Wire the View.TextField per input โ decoration.errorText reads from state.<field>.displayError (mapped through a _message() helper). Submit button uses .
Address selection (saved-addresses list, "use my current location", far-address warning) is prd.md ยง2.1. Address entry fields are prd.md ยง27: apartment number (ุฑูู ุงูุดูุฉ), floor (ุงูุทุงุจู), landmark (ุนูุงู ุฉ ู ู ูุฒุฉ), plus special instructions (ุชุนููู ุงุช ุฎุงุตุฉ ุจุงูุนููุงู). The form Bloc holds an ApartmentInput, FloorInput, LandmarkInput, SpecialInstructionsInput, and a CityInput populated automatically from a reverse-geocode lookup (the city field is shown read-only and is "dirty" on initial load โ pure inputs whose validator rejects empty strings would block submit).
Submitting POSTs to /api/users/me/addresses. The Laravel side validates that apartment is non-empty and city_id exists in the cities table. A 422 with {"apartment": ["The apartment field is required."]} maps to state.copyWith(apartment: ApartmentInput.dirty(state.apartment.value, errors['apartment']!.first)) โ the user sees the error inline.
OtpVerifyBloc (PRD_states.md ยง14)
The user lifecycle is pending_verification โ active via phone/email OTP. The form has a single OtpCodeInput with a fixed-length validator (6 digits) and a submit. Server-side errors include 422 ({"code": ["Invalid code"]}); expired codes come back as Failure.unknown or a custom code your repository maps to Failure.unknown(...), surfaced as state.serverError rather than a per-field error. A Resend event triggers a separate repository call gated by a cooldown timer โ Stream.periodic(Duration(seconds: 1)) consumed via a Ticked event handler is the lightest implementation; no real-time transport, so flutter-bloc-stream-tracking does not apply.
Examples
A LoginFormBloc complete with field inputs, state, events, handler, and view. The form has email and password; the API is POST /api/login returning either a 200 with a token or a 422 with field errors.
lib/core/forms/email_input.dart
import 'package:formz/formz.dart';
enum EmailValidationError { empty, malformed }
class EmailInput extends FormzInput<String, EmailValidationError> {
// `pure` accepts an optional seed value so an edit-profile screen can
// pre-populate the field without making it dirty. `serverError` is always
// null for pure inputs (you only get a server error after submitting).
// Dart requires every final field to be set by every constructor.
const EmailInput.pure([String value = ''])
: serverError = null,
super.pure(value);
const EmailInput.dirty([super.value = '', this.serverError]) : super.dirty();
final String? serverError;
static final _re = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
@override
EmailValidationError? validator(String value) {
if (value.isEmpty) return EmailValidationError.empty;
if (!_re.hasMatch(value)) return EmailValidationError.malformed;
return null;
}
// FormzInput's default `==` covers `isPure` and `value` only โ we add
// `serverError` so a `BlocSelector<EmailInput>` rebuilds when only the
// server message changes (same value, same purity, new error string).
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is EmailInput &&
other.value == value &&
other.isPure == isPure &&
other.serverError == serverError);
@override
int get hashCode => Object.hash(value, isPure, serverError);
}
lib/core/forms/password_input.dart
import 'package:formz/formz.dart';
enum PasswordValidationError { empty, tooShort }
class PasswordInput extends FormzInput<String, PasswordValidationError> {
const PasswordInput.pure([String value = ''])
: serverError = null,
super.pure(value);
const PasswordInput.dirty([super.value = '', this.serverError]) : super.dirty();
final String? serverError;
@override
PasswordValidationError? validator(String value) {
if (value.isEmpty) return PasswordValidationError.empty;
if (value.length < 8) return PasswordValidationError.tooShort;
return null;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is PasswordInput &&
other.value == value &&
other.isPure == isPure &&
other.serverError == serverError);
@override
int get hashCode => Object.hash(value, isPure, serverError);
}
errorText: email.serverError ?? _emailMsg(email.displayError) is the whole 422 round-trip in one line: server error wins when present, otherwise client-side validator output (or null when the field is still pure).
Step 10 โ Feedback loop. Run on device โ submit with invalid data โ assert per-field errors render โ submit with data that triggers a server 422 โ assert those errors render on the right fields. If a 422 error message shows in a snackbar instead of next to the field, the Bloc swallowed ValidationFailure into serverError instead of splatting it.