Flutter form validation with hooks
A form is a screen with two extra concerns over a plain screen: each input owns a value and a validation error, and a TextField needs a TextEditingController whose lifecycle does not compose with hook rebuilds. Hand-rolling both - a useState per field, a useState<String?> per error, a useMemoized(TextEditingController.new) per input, plus effects to keep them in sync - is where form code rots.
useFieldState collapses the value-and-error pair into one object, and TextEditingControllerWrapper owns the controller for you. The field state is a normal piece of the State class, so validation and submit gating follow the same rules as the rest of the screen.
A field is a value plus an error
useFieldState returns a MutableFieldState: a MutableValue<String> you read and write through .value, carrying an errorMessage alongside it. Put the field states straight onto the State class - they hold both the data the View renders and the slot the validation display reads.
class SignUpScreenState {
// Data - the field states carry both value and error message
final MutableFieldState emailField;
final MutableFieldState passwordField;
final bool isSubmitting;
// Actions
final void Function() onSubmitPressed;
const SignUpScreenState({
required this.emailField,
required this.passwordField,
required this.isSubmitting,
required this.onSubmitPressed,
});
}
The hook creates one field per input and a useSubmitState for the write. Nothing about a field requires a BuildContext, so the hook stays testable without a widget tree.
SignUpScreenState useSignUpScreenState({
required SignUpScreenArgs args,
required void Function() navigateToHome,
}) {
final emailField = useFieldState(initialValue: args.prefilledEmail ?? "");
final passwordField = useFieldState();
final submitState = useSubmitState();
void onSubmitPressed() => submitState.runSimple<void, Never>(
// shouldSubmit runs validation - see the page for the validator bodies
submit: () async {
// await authService.signUp(emailField.value, passwordField.value);
},
afterSubmit: (_) => navigateToHome(),
);
return SignUpScreenState(
emailField: emailField,
passwordField: passwordField,
isSubmitting: submitState.inProgress,
onSubmitPressed: onSubmitPressed,
);
}
useFieldState({String? initialValue}) takes a named, optional initial value defaulting to "". For a field that holds something other than a String - a dropdown selection, a date, a toggle - reach for useGenericFieldState<T>, which is the same state with T left open:
// A non-string field: a country dropdown that still wants a validation slot.
class CountryPickerState {
final MutableGenericFieldState<String?> countryField;
const CountryPickerState({required this.countryField});
}
CountryPickerState useCountryPickerState() {
final countryField = useGenericFieldState<String?>(initialValue: null);
return CountryPickerState(countryField: countryField);
}
Binding the field to a TextField
A TextField is backed by a TextEditingController, and that controller carries selection and composing state that a hook rebuild must not stomp on. Do not manage it by hand. TextEditingControllerWrapper owns the controller - it creates and disposes it, writes the field as the user types, and pushes external value changes back into the controller.
class SignUpScreenView extends StatelessWidget {
final SignUpScreenState state;
const SignUpScreenView({required this.state});
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
_EmailField(field: state.emailField),
// ... password field, submit button
],
),
);
}
}
class _EmailField extends StatelessWidget {
final MutableFieldState field;
const _EmailField({required this.field});
Widget build(BuildContext context) {
// errorMessage is a ValidatorResult? - a String Function(BuildContext) -
// so it is resolved against this context only at render time.
final errorMessage = field.errorMessage;
return TextEditingControllerWrapper(
text: field, // <- the field state is the single source of truth
builder: (controller) => TextField(
controller: controller,
decoration: InputDecoration(
labelText: "Email",
errorText: errorMessage != null ? errorMessage(context) : null,
),
),
);
}
}
The wrapper takes text (the MutableValue<String> source of truth), not a state. The field is the single owner of the value; the controller is a managed view onto it. Because the field state is a MutableValue<String>, you pass it directly.
Writing text.value from elsewhere flows into the controller, so a "clear" button or a prefill is just a value assignment:
// A "clear" button writes the field value; the wrapper pushes it into the
// controller, so the text box empties without you touching a TextEditingController.
void clearEmail(MutableFieldState emailField) {
emailField.value = "";
emailField.errorMessage = null; // changing the value does not clear the error
}
Validation
The error message is not a String. It is a ValidatorResult? - a String Function(BuildContext)? - so the text is resolved against a BuildContext at render time and can be localized. The state hook only ever assigns the closure; the View resolves it (the errorText line in the snippet above does exactly this).
// DON'T - a String is not a ValidatorResult
emailField.errorMessage = 'Invalid email';
// DO - a closure resolved at render time, in the View
emailField.errorMessage = (context) => 'Invalid email';
The validator API (Validator<T>, the .validate() method, the Validators helpers below) lives in utopia_validation, a separate package that ships transitively with utopia_hooks. Import it directly to name those types:
import 'package:utopia_validation/utopia_validation.dart';
A Validator<T> is a ValidatorResult? Function(T) - it returns null when the value is valid, or an error builder when it is not. MutableFieldState carries a .validate(validator) method (from utopia_validation's Validatable interface) that runs the validator, stores the result in errorMessage as a side effect, and returns whether the field is valid:
// utopia_validation is imported
emailField.validate(
(value) => value.contains('@') ? null : (context) => 'Invalid email',
);
// returns false and sets emailField.errorMessage when the value has no '@'
Build validators with the Validators combinators rather than hand-written conditionals - combine runs a list in order and stops at the first failure:
final emailValidator = Validators.combine<String>([
Validators.notEmpty(onEmpty: (context) => 'Field cannot be empty'),
Validators.conditional(
(value) => !value.contains('@'),
onFalse: (context) => 'Invalid email',
),
]);
Validate in the submit gate
Run every field in the submit's shouldSubmit and abort if any fails. validate() already sets each field's errorMessage, so there is no separate isFormValid() helper to keep in sync - the act of checking validity is the act of populating the error messages.
bool validate() => [
emailField.validate(emailValidator),
passwordField.validate(passwordValidator),
].every((it) => it); // every field validates; .every keeps all messages set
void onSubmitPressed() => submitState.runSimple<void, Never>(
shouldSubmit: () => validate(), // the one gate
submit: () => authService.signUp(emailField.value, passwordField.value),
afterSubmit: (_) => navigateToHome(),
);
Collecting the results into a list before .every is deliberate: it forces all validators to run, so every invalid field shows its message at once rather than only the first.
Patterns and pitfalls
-
Keep validators out of the View and out of the hook body as static
Validator<T>values (anAppValidatorsholder, or top-level finals). The hook references them; it does not define regexes inline. -
Clear stale server-side errors in
beforeSubmit. An error set from a previous failed API call (field.errorMessage = (context) => 'Email already taken') lingers untilvalidate()overwrites it or you reset it tonull. Reset before re-submitting. -
The value and the error are independent. Editing
.valuedoes not clearerrorMessage- re-runvalidate(), or assignnull, when you want the error to disappear as the user types.
Never reach for useMemoized(TextEditingController.new) plus a useEffect that writes controller.text for an editable field. The effect fires on every external rebuild and resets the cursor to the end mid-typing. useFieldState + TextEditingControllerWrapper exists precisely to avoid this - the wrapper only writes the controller when the value genuinely differs.
See also
- useFieldState - the
Stringfield state, signature and caveats - useGenericFieldState - the typed field state, full validation coverage
- TextEditingControllerWrapper - the controller-binding widget
- useSubmitState - the write hook that
validate()gates - Form Validation example - a full sign-up form end to end