Skip to content
AB

Flutter Forms Are State Machines, Not Widgets

Build Flutter forms that handle touched fields, dirty values, validation, submission, and reset without mixing everything into the UI.

Ashish BhakhandPublished

A small login form can live comfortably inside one StatefulWidget. A registration or checkout form usually cannot.

Real forms need to answer questions such as:

  • Has the user interacted with this field?
  • Has its value changed?
  • Should its error be visible yet?
  • Do two fields agree with each other?
  • Is a request currently being submitted?
  • Should reset restore empty values or values loaded from the server?

TextFormField only renders an input. The answers above are the actual form state.

Start with the states users can see#

Take a registration form with email, password, and password confirmation. The screen may be:

Code
enum SubmissionStatus {
  idle,
  submitting,
  success,
  failure,
}

Each field also has its own state:

Code
class RegistrationState {
  const RegistrationState({
    this.email = '',
    this.password = '',
    this.confirmPassword = '',
    this.emailTouched = false,
    this.passwordTouched = false,
    this.confirmPasswordTouched = false,
    this.status = SubmissionStatus.idle,
  });

  final String email;
  final String password;
  final String confirmPassword;
  final bool emailTouched;
  final bool passwordTouched;
  final bool confirmPasswordTouched;
  final SubmissionStatus status;
}

This example is intentionally plain. The important improvement is that form behavior is represented as data instead of being spread across controllers, widgets, and local booleans.

Touched and dirty mean different things#

A field is touched after the user interacts with it. A field is dirty when its current value differs from its initial value.

They answer different questions:

Code
final emailIsDirty = state.email != initialEmail;
final shouldShowEmailError =
    state.emailTouched && emailError(state.email) != null;

A user can focus and leave a field without changing it. That field is touched but not dirty.

A form can also load existing values and then be edited. Comparing against an initial value lets you disable Save until something changes or warn before discarding edits.

Validate without showing errors too early#

This validator only decides whether a value is valid:

Code
String? emailError(String value) {
  if (value.trim().isEmpty) {
    return 'Email is required';
  }

  if (!value.contains('@')) {
    return 'Enter a valid email address';
  }

  return null;
}

The UI decides when to show the result:

Code
TextField(
  onChanged: controller.emailChanged,
  onTapOutside: (_) => controller.emailTouched(),
  decoration: InputDecoration(
    labelText: 'Email',
    errorText: state.emailTouched
        ? emailError(state.email)
        : null,
  ),
)

This prevents the screen from displaying three errors before the user types anything.

On submit, mark every field as touched. The user should then see everything that blocks submission:

Code
Future<void> submit() async {
  state = state.copyWith(
    emailTouched: true,
    passwordTouched: true,
    confirmPasswordTouched: true,
  );

  if (!isValid(state)) return;

  state = state.copyWith(status: SubmissionStatus.submitting);

  final result = await repository.register(
    email: state.email,
    password: state.password,
  );

  state = result.isSuccess
      ? state.copyWith(status: SubmissionStatus.success)
      : state.copyWith(status: SubmissionStatus.failure);
}

Flutter's form validation cookbook is a good introduction to the built-in Form and validator approach. The same touched-and-submit rules apply whether you use Form, BLoC, Riverpod, or another state solution.

Put cross-field rules at form level#

Password confirmation cannot be validated correctly from the confirmation value alone. It depends on the password:

Code
String? confirmationError(RegistrationState state) {
  if (state.confirmPassword.isEmpty) {
    return 'Confirm your password';
  }

  if (state.password != state.confirmPassword) {
    return 'Passwords do not match';
  }

  return null;
}

Other common cross-field rules include:

  • end date must be after start date;
  • at least one contact method is required;
  • a delivery address is required only for physical products;
  • a company tax number is required for business accounts.

Keep these rules with the whole form rather than making one field reach into another field's controller.

Imagine the password and confirmation currently match. The user then changes the password. The confirmation must immediately become invalid.

That means changes to one field may require validation of another field:

Code
void passwordChanged(String value) {
  state = state.copyWith(password: value);

  if (state.confirmPasswordTouched) {
    notifyListeners();
  }
}

With immutable state management, emitting the new form state naturally rebuilds both fields. With mutable models, make sure form-level validation runs after every relevant update.

Keep network validation separate#

Some checks require the server:

  • Is this username available?
  • Is the coupon still valid?
  • Does the tax number exist?

Do not start a network request inside a synchronous field validator. Track it separately:

Code
enum UsernameStatus {
  idle,
  checking,
  available,
  unavailable,
}

Debounce requests while the user types, and ignore a response if it belongs to an older value. Otherwise a slow response for “ashi” can overwrite a newer result for “ashish”.

Server validation returned during submission should also map to a useful field or form error. Clear that error when the related input changes.

Disable submission for a reason#

A submit button commonly depends on several pieces of state:

Code
final canSubmit =
    isValid(state) &&
    state.status != SubmissionStatus.submitting &&
    hasChanges(state);

During submission, keep the state explicit:

Code
FilledButton(
  onPressed: canSubmit ? controller.submit : null,
  child: state.status == SubmissionStatus.submitting
      ? const SizedBox.square(
          dimension: 20,
          child: CircularProgressIndicator(strokeWidth: 2),
        )
      : const Text('Create account'),
)

This prevents duplicate requests and makes the loading state easy to test.

Reset all form state#

Reset should restore:

  • initial field values;
  • touched state;
  • field and form errors;
  • submission status.

For an edit form, the initial values may be server data rather than empty strings. Store that initial snapshot instead of assuming reset always means blank.

Test behavior without rendering the full page#

Most form behavior can be tested through its controller or notifier:

Code
test('submit reveals password mismatch', () async {
  final controller = RegistrationController(fakeRepository);

  controller.passwordChanged('Password123');
  controller.confirmPasswordChanged('Password456');

  await controller.submit();

  expect(controller.state.confirmPasswordTouched, isTrue);
  expect(
    confirmationError(controller.state),
    'Passwords do not match',
  );
  verifyNever(() => fakeRepository.register(
        email: any(named: 'email'),
        password: any(named: 'password'),
      ));
});

Add widget tests for the connection between the state and UI: typing updates the controller, errors appear at the right time, and loading disables the button.

Using Fabrik for this#

fabrik_forms provides reusable field and form state, touched and dirty tracking, field and cross-field validation, reset behavior, and a Flutter notifier.

You can also build the same structure inside your existing state-management solution. The main goal is to keep form rules in testable state code and let widgets focus on input and presentation.

← Back to all writing