Skip to main content

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';
note

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 (an AppValidators holder, 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 until validate() overwrites it or you reset it to null. Reset before re-submitting.

  • The value and the error are independent. Editing .value does not clear errorMessage - re-run validate(), or assign null, when you want the error to disappear as the user types.

caution

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​