flutter
/

Form Validation with Bloc: Building Robust, Reactive Forms

Last Sync: Today

On this page

7
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

Form Validation with Bloc: Building Robust, Reactive Forms

Form validation is a common requirement in almost every app. Using Bloc (or Cubit) for form validation gives you a reactive, testable, and maintainable approach. This guide covers everything from basic field validation to complex multi‑step forms with asynchronous validation and submission handling.

Why Use Bloc for Form Validation?

  • Reactive UI – The UI automatically updates when validation state changes.
  • Separation of concerns – Validation logic lives in the bloc/cubit, not scattered in widgets.
  • Testability – You can unit‑test validation rules without building widgets.
  • Centralised state – Form state (validity, submission status) is managed in one place.
  • Consistency – Same validation rules apply everywhere, preventing duplication.

Basic Form Validation with Cubit

For simple forms, Cubit is often enough. Let's build a login form with email and password validation, and a submit button that is enabled only when the form is valid.

DARTRead-only
1
import 'package:equatable/equatable.dart';

class LoginState extends Equatable {
  final String email;
  final String password;
  final bool isValid;
  final bool isSubmitting;
  final String? emailError;
  final String? passwordError;

  const LoginState({
    this.email = '',
    this.password = '',
    this.isValid = false,
    this.isSubmitting = false,
    this.emailError,
    this.passwordError,
  });

  LoginState copyWith({
    String? email,
    String? password,
    bool? isValid,
    bool? isSubmitting,
    String? emailError,
    String? passwordError,
  }) {
    return LoginState(
      email: email ?? this.email,
      password: password ?? this.password,
      isValid: isValid ?? this.isValid,
      isSubmitting: isSubmitting ?? this.isSubmitting,
      emailError: emailError ?? this.emailError,
      passwordError: passwordError ?? this.passwordError,
    );
  }

  @override
  List<Object?> get props => [email, password, isValid, isSubmitting, emailError, passwordError];
}
DARTRead-only
1
import 'package:flutter_bloc/flutter_bloc.dart';

class LoginCubit extends Cubit<LoginState> {
  LoginCubit() : super(const LoginState());

  void emailChanged(String value) {
    final emailError = _validateEmail(value);
    final passwordError = state.passwordError;
    final isValid = emailError == null && passwordError == null;

    emit(state.copyWith(
      email: value,
      emailError: emailError,
      isValid: isValid,
    ));
  }

  void passwordChanged(String value) {
    final passwordError = _validatePassword(value);
    final emailError = state.emailError;
    final isValid = emailError == null && passwordError == null;

    emit(state.copyWith(
      password: value,
      passwordError: passwordError,
      isValid: isValid,
    ));
  }

  String? _validateEmail(String email) {
    if (email.isEmpty) return 'Email is required';
    final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    if (!emailRegex.hasMatch(email)) return 'Enter a valid email';
    return null;
  }

  String? _validatePassword(String password) {
    if (password.isEmpty) return 'Password is required';
    if (password.length < 6) return 'Password must be at least 6 characters';
    return null;
  }

  Future<void> submit() async {
    if (!state.isValid) return;

    emit(state.copyWith(isSubmitting: true));

    try {
      // Simulate API call
      await Future.delayed(const Duration(seconds: 2));
      // If success, emit success state (e.g., navigate)
      // In a real app you'd emit a LoginSuccess state
      emit(state.copyWith(isSubmitting: false));
    } catch (e) {
      emit(state.copyWith(isSubmitting: false));
      // Emit error state if needed
    }
  }
}
DARTRead-only
1
class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Login')),
      body: BlocProvider(
        create: (_) => LoginCubit(),
        child: const LoginForm(),
      ),
    );
  }
}

class LoginForm extends StatelessWidget {
  const LoginForm({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<LoginCubit, LoginState>(
      builder: (context, state) {
        return Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            children: [
              TextField(
                onChanged: (value) => context.read<LoginCubit>().emailChanged(value),
                decoration: InputDecoration(
                  labelText: 'Email',
                  errorText: state.emailError,
                ),
              ),
              const SizedBox(height: 16),
              TextField(
                onChanged: (value) => context.read<LoginCubit>().passwordChanged(value),
                obscureText: true,
                decoration: InputDecoration(
                  labelText: 'Password',
                  errorText: state.passwordError,
                ),
              ),
              const SizedBox(height: 24),
              ElevatedButton(
                onPressed: state.isSubmitting || !state.isValid
                    ? null
                    : () => context.read<LoginCubit>().submit(),
                child: state.isSubmitting
                    ? const SizedBox(
                        width: 20,
                        height: 20,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Text('Login'),
              ),
            ],
          ),
        );
      },
    );
  }
}

Using Bloc for Complex Forms

For larger forms with many fields or complex validation (e.g., cross‑field validation), using Bloc with events gives you more structure and traceability. Let's build a registration form with username, email, password, and password confirmation.

DARTRead-only
1
abstract class RegisterEvent {}

class UsernameChanged extends RegisterEvent {
  final String username;
  UsernameChanged(this.username);
}

class EmailChanged extends RegisterEvent {
  final String email;
  EmailChanged(this.email);
}

class PasswordChanged extends RegisterEvent {
  final String password;
  PasswordChanged(this.password);
}

class ConfirmPasswordChanged extends RegisterEvent {
  final String confirmPassword;
  ConfirmPasswordChanged(this.confirmPassword);
}

class RegisterSubmitted extends RegisterEvent {}
DARTRead-only
1
class RegisterState extends Equatable {
  final String username;
  final String email;
  final String password;
  final String confirmPassword;
  final bool isValid;
  final bool isSubmitting;
  final FormSubmissionStatus submissionStatus;
  final String? usernameError;
  final String? emailError;
  final String? passwordError;
  final String? confirmPasswordError;

  const RegisterState({
    this.username = '',
    this.email = '',
    this.password = '',
    this.confirmPassword = '',
    this.isValid = false,
    this.isSubmitting = false,
    this.submissionStatus = FormSubmissionStatus.idle,
    this.usernameError,
    this.emailError,
    this.passwordError,
    this.confirmPasswordError,
  });

  RegisterState copyWith({...}) { ... }

  @override
  List<Object?> get props => [...];
}

enum FormSubmissionStatus { idle, inProgress, success, failure }
DARTRead-only
1
class RegisterBloc extends Bloc<RegisterEvent, RegisterState> {
  final AuthRepository repository;

  RegisterBloc(this.repository) : super(const RegisterState()) {
    on<UsernameChanged>(_onUsernameChanged);
    on<EmailChanged>(_onEmailChanged);
    on<PasswordChanged>(_onPasswordChanged);
    on<ConfirmPasswordChanged>(_onConfirmPasswordChanged);
    on<RegisterSubmitted>(_onRegisterSubmitted);
  }

  void _onUsernameChanged(UsernameChanged event, Emitter<RegisterState> emit) {
    final error = _validateUsername(event.username);
    emit(state.copyWith(
      username: event.username,
      usernameError: error,
      isValid: _isFormValid(event.username, state.email, state.password, state.confirmPassword),
    ));
  }

  void _onEmailChanged(EmailChanged event, Emitter<RegisterState> emit) {
    final error = _validateEmail(event.email);
    emit(state.copyWith(
      email: event.email,
      emailError: error,
      isValid: _isFormValid(state.username, event.email, state.password, state.confirmPassword),
    ));
  }

  void _onPasswordChanged(PasswordChanged event, Emitter<RegisterState> emit) {
    final passwordError = _validatePassword(event.password);
    final confirmPasswordError = _validateConfirmPassword(event.password, state.confirmPassword);
    emit(state.copyWith(
      password: event.password,
      passwordError: passwordError,
      confirmPasswordError: confirmPasswordError,
      isValid: _isFormValid(state.username, state.email, event.password, state.confirmPassword),
    ));
  }

  void _onConfirmPasswordChanged(ConfirmPasswordChanged event, Emitter<RegisterState> emit) {
    final error = _validateConfirmPassword(state.password, event.confirmPassword);
    emit(state.copyWith(
      confirmPassword: event.confirmPassword,
      confirmPasswordError: error,
      isValid: _isFormValid(state.username, state.email, state.password, event.confirmPassword),
    ));
  }

  Future<void> _onRegisterSubmitted(RegisterSubmitted event, Emitter<RegisterState> emit) async {
    if (!state.isValid) return;

    emit(state.copyWith(isSubmitting: true, submissionStatus: FormSubmissionStatus.inProgress));

    try {
      await repository.register(
        username: state.username,
        email: state.email,
        password: state.password,
      );
      emit(state.copyWith(
        isSubmitting: false,
        submissionStatus: FormSubmissionStatus.success,
      ));
    } catch (e) {
      emit(state.copyWith(
        isSubmitting: false,
        submissionStatus: FormSubmissionStatus.failure,
      ));
    }
  }

  bool _isFormValid(String username, String email, String password, String confirmPassword) {
    return _validateUsername(username) == null &&
        _validateEmail(email) == null &&
        _validatePassword(password) == null &&
        _validateConfirmPassword(password, confirmPassword) == null;
  }

  String? _validateUsername(String value) { ... }
  String? _validateEmail(String value) { ... }
  String? _validatePassword(String value) { ... }
  String? _validateConfirmPassword(String password, String confirm) { ... }
}

Use BlocConsumer to handle both UI rebuilds and side effects (navigation, snackbars).

DARTRead-only
1
BlocConsumer<RegisterBloc, RegisterState>(
  listener: (context, state) {
    if (state.submissionStatus == FormSubmissionStatus.success) {
      Navigator.pushReplacementNamed(context, '/home');
    }
    if (state.submissionStatus == FormSubmissionStatus.failure) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Registration failed')),
      );
    }
  },
  builder: (context, state) {
    // Build form UI with TextFields and submit button
  },
)

Asynchronous Validation

Sometimes you need to validate against a server (e.g., check if username is already taken). This requires async validation, which you can handle inside the bloc with debouncing.

DARTRead-only
1
on<UsernameChanged>((event, emit) async {
  emit(state.copyWith(isValidatingUsername: true));
  final error = await repository.checkUsernameAvailability(event.username);
  emit(state.copyWith(
    username: event.username,
    usernameError: error != null ? 'Username taken' : null,
    isValidatingUsername: false,
  ));
}, transformer: debounce(const Duration(milliseconds: 500)));

Best Practices

  • Separate validation functions – Keep validation logic in pure functions for easy testing.
  • Use copyWith to update state immutably – Ensures predictable state changes.
  • Leverage Equatable – Prevents unnecessary rebuilds when state doesn't change.
  • Debounce async validation – Use event transformers to avoid excessive API calls.
  • Show loading indicators – Provide visual feedback during submission and async validation.
  • Disable submit button while submitting – Prevents multiple submissions.
  • Handle submission errors gracefully – Show user-friendly error messages.

Common Mistakes

  • ❌ Putting validation logic directly in the UI – Leads to code duplication and makes testing hard.
  • ❌ Not resetting error messages – Errors can persist after the user fixes the field if not cleared.
  • ❌ Re‑evaluating validation on every keystroke without debouncing async validations – Causes unnecessary network calls.
  • ❌ Not handling focus and cursor position – When rebuilding the entire form, focus may be lost. Use AutovalidateMode carefully.
  • ❌ Ignoring form submission state – Users need to know if the form is processing.

What's Next?

Now that you can handle forms, explore how to structure larger apps with modular architecture and how to test your blocs.

Next, explore Bloc testing and Bloc architecture.

Test Your Knowledge

Q1
of 3

What is the main advantage of putting form validation logic in a bloc/cubit?

A
It makes the UI code shorter
B
It centralises validation logic and makes it testable
C
It automatically debounces input
D
It reduces network calls
Q2
of 3

Which widget should you use to handle both UI rebuilds and side effects like navigation after form submission?

A
BlocBuilder
B
BlocListener
C
BlocConsumer
D
BlocProvider
Q3
of 3

How can you avoid excessive async validation calls while the user types?

A
Use `Equatable`
B
Use an event transformer with debounce
C
Use `copyWith`
D
Use `MultiBlocProvider`

Frequently Asked Questions

Should I use Cubit or Bloc for form validation?

For simple forms, Cubit is sufficient and less verbose. For complex forms with many fields, cross‑field validation, or async validation, Bloc provides better organisation and traceability.

How do I handle multiple forms on one screen?

Use separate blocs/cubits for each form. You can use MultiBlocProvider to provide them, and each form widget will rebuild independently.

What about using Flutter's Form widget with GlobalKey?

You can combine them. Use the Form widget for built‑in validation and focus management, but still keep the validation logic and form data in your bloc/cubit for testability and centralised state.

How do I reset the form after successful submission?

Emit a fresh state (e.g., const RegisterState()) after successful submission, or keep the state and use a listener to reset fields if needed.

Previous

bloc api integration

Next

bloc authentication

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.