flutter
/

BLoC Error Handling: Robust State Management with Proper Error Handling

Last Sync: Today

On this page

12
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Error Handling: Robust State Management with Proper Error Handling

Introduction

Error handling is a critical part of any robust Flutter application. In BLoC, errors can occur in event handlers, during API calls, or from streams. Proper error handling ensures your app remains stable, provides meaningful feedback to users, and helps with debugging. This guide covers everything from basic try/catch to global error tracking with BlocObserver, and how to structure error states for a better user experience.

Why Error Handling Matters

  • User Experience – Show friendly error messages instead of crashes.
  • Stability – Prevent unexpected state changes or app freezes.
  • Debugging – Log errors to analytics or crash reporting tools.
  • Data Integrity – Ensure that errors don’t corrupt your app state.

Basic Error Handling in Event Handlers

The simplest way to handle errors is to wrap your event handler logic in a try/catch block. Then emit an error state so the UI can react appropriately.

DARTRead-only
1
class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final AuthRepository authRepository;

  LoginBloc(this.authRepository) : super(LoginInitial()) {
    on<LoginSubmitted>(_onLoginSubmitted);
  }

  Future<void> _onLoginSubmitted(LoginSubmitted event, Emitter<LoginState> emit) async {
    emit(LoginLoading());
    try {
      final user = await authRepository.login(event.email, event.password);
      emit(LoginSuccess(user));
    } catch (e) {
      emit(LoginFailure(e.toString()));
    }
  }
}

Defining Error States

Including error information in your state class allows the UI to display meaningful messages. You can also include fields like errorCode or shouldRetry for more complex scenarios.

DARTRead-only
1
@immutable
class LoginState extends Equatable {
  final FormStatus status;
  final String? errorMessage;
  final User? user;

  const LoginState({
    this.status = FormStatus.initial,
    this.errorMessage,
    this.user,
  });

  LoginState copyWith({
    FormStatus? status,
    String? errorMessage,
    User? user,
  }) {
    return LoginState(
      status: status ?? this.status,
      errorMessage: errorMessage ?? this.errorMessage,
      user: user ?? this.user,
    );
  }

  @override
  List<Object?> get props => [status, errorMessage, user];
}

enum FormStatus { initial, loading, success, failure }

// In the BLoC
emit(state.copyWith(status: FormStatus.failure, errorMessage: e.toString()));

Using onError in Bloc/Cubit

Both Bloc and Cubit provide an onError method that is called whenever an uncaught exception occurs inside an event handler. You can override it to log errors, send them to a reporting service, or show a snackbar.

DARTRead-only
1
class MyBloc extends Bloc<MyEvent, MyState> {
  MyBloc() : super(MyState()) {
    on<MyEvent>(_onEvent);
  }

  @override
  void onError(Object error, StackTrace stackTrace) {
    print('BLoC error: $error');
    // Send to crash reporting service
    // You can also emit a special error state here if needed
    super.onError(error, stackTrace);
  }

  Future<void> _onEvent(MyEvent event, Emitter<MyState> emit) async {
    // If an uncaught error occurs here, onError will catch it
  }
}

Global Error Handling with BlocObserver

BlocObserver lets you observe all BLoC events, transitions, and errors across your entire app. You can create a custom observer to log errors centrally.

DARTRead-only
1
class MyBlocObserver extends BlocObserver {
  @override
  void onError(Bloc bloc, Object error, StackTrace stackTrace) {
    print('Global error in ${bloc.runtimeType}: $error');
    // Send to crash reporting
    super.onError(bloc, error, stackTrace);
  }
}

void main() {
  Bloc.observer = MyBlocObserver();
  runApp(MyApp());
}

Handling Errors in Streams

When listening to streams (e.g., using emit.forEach), you must handle errors to prevent the BLoC from crashing. Use the onError parameter or catch errors in the subscription.

DARTRead-only
1
on<StartStream>((event, emit) async {
  await emit.forEach(
    someStream,
    onData: (data) => StreamState.success(data),
    onError: (error, stackTrace) => StreamState.error(error.toString()),
  );
});

Recovering from Errors

After an error, you may want to allow the user to retry or revert to a safe state. For example, you can add a Retry event to re-attempt the failed operation.

DARTRead-only
1
class DataBloc extends Bloc<DataEvent, DataState> {
  DataBloc() : super(DataInitial()) {
    on<LoadData>(_onLoadData);
    on<RetryLoadData>(_onRetryLoadData);
  }

  Future<void> _onLoadData(LoadData event, Emitter<DataState> emit) async {
    emit(DataLoading());
    try {
      final data = await api.fetchData();
      emit(DataSuccess(data));
    } catch (e) {
      emit(DataError('Failed to load: $e'));
    }
  }

  Future<void> _onRetryLoadData(RetryLoadData event, Emitter<DataState> emit) async {
    // Reuse the load logic, optionally resetting state first
    emit(DataLoading());
    try {
      final data = await api.fetchData();
      emit(DataSuccess(data));
    } catch (e) {
      emit(DataError('Failed to load: $e'));
    }
  }
}

Error Handling in Services and Repositories

While your BLoC catches errors, it’s also important to handle them in the service layer. Convert raw exceptions into domain‑specific errors (e.g., NetworkException, AuthException) so your BLoC can respond appropriately.

DARTRead-only
1
class AuthRepository {
  Future<User> login(String email, String password) async {
    try {
      final response = await http.post(...);
      if (response.statusCode == 200) {
        return User.fromJson(response.body);
      } else if (response.statusCode == 401) {
        throw AuthException('Invalid credentials');
      } else {
        throw ServerException('Something went wrong');
      }
    } on SocketException {
      throw NetworkException('No internet connection');
    }
  }
}

// In BLoC
try {
  final user = await authRepository.login(...);
  emit(LoginSuccess(user));
} on AuthException catch (e) {
  emit(LoginFailure(e.message));
} on NetworkException catch (e) {
  emit(LoginFailure(e.message));
}

Best Practices

  • Always handle errors – Never leave a try/catch empty. Emit an error state or at least log the error.
  • Use custom error states – Include a message, error code, and possibly a retry callback.
  • Leverage BlocObserver – For global logging, analytics, and crash reporting.
  • Don’t ignore onError – Override it to catch unhandled exceptions in your BLoC.
  • Convert low‑level errors to domain errors – Makes your BLoC logic simpler and more testable.
  • Provide retry mechanisms – Allow users to recover from transient errors.
  • Test error scenarios – Write tests that simulate exceptions and verify state transitions.

Common Mistakes

  • ❌ Catching errors without emitting an error state – UI stays in loading state forever. ✅ Always emit an error state or revert to a safe state.
  • ❌ Swallowing errors silently – Makes debugging impossible. ✅ Log errors with print or send to analytics.
  • ❌ Using onError to emit states – This can cause race conditions; prefer emitting from the handler itself.
  • ❌ Not handling errors in emit.forEach – The BLoC will crash. ✅ Always provide the onError parameter.
  • ❌ Overusing catch (e) – Using a generic catch without checking error types can hide specific exceptions. ✅ Catch specific exceptions when possible.

Conclusion

Effective error handling is essential for building reliable Flutter apps with BLoC. By combining try/catch, custom error states, onError, and BlocObserver, you can create a robust system that gracefully handles failures, provides meaningful feedback, and helps you diagnose issues quickly. Always design your BLoCs with error handling in mind from the start.

Try it yourself

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

void main() => runApp(MyApp());

// States
abstract class LoginState {}
class LoginInitial extends LoginState {}
class LoginLoading extends LoginState {}
class LoginSuccess extends LoginState {}
class LoginFailure extends LoginState {
  final String error;
  LoginFailure(this.error);
}

// Events
abstract class LoginEvent {}
class LoginSubmitted extends LoginEvent {
  final String email;
  final String password;
  LoginSubmitted(this.email, this.password);
}

// Mock repository
class AuthRepository {
  Future<void> login(String email, String password) async {
    await Future.delayed(Duration(seconds: 2));
    if (email == 'error@test.com') {
      throw Exception('Invalid credentials');
    }
    // Success
    return;
  }
}

// BLoC
class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final AuthRepository authRepository;

  LoginBloc(this.authRepository) : super(LoginInitial()) {
    on<LoginSubmitted>(_onLoginSubmitted);
  }

  Future<void> _onLoginSubmitted(LoginSubmitted event, Emitter<LoginState> emit) async {
    emit(LoginLoading());
    try {
      await authRepository.login(event.email, event.password);
      emit(LoginSuccess());
    } catch (e) {
      emit(LoginFailure(e.toString()));
    }
  }
}

// UI
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => LoginBloc(AuthRepository()),
        child: LoginPage(),
      ),
    );
  }
}

class LoginPage extends StatelessWidget {
  final TextEditingController emailController = TextEditingController();
  final TextEditingController passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Error Handling Demo')),
      body: Padding(
        padding: EdgeInsets.all(16),
        child: BlocConsumer<LoginBloc, LoginState>(
          listener: (context, state) {
            if (state is LoginSuccess) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('Login successful!')),
              );
            } else if (state is LoginFailure) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('Error: ${state.error}')),
              );
            }
          },
          builder: (context, state) {
            if (state is LoginLoading) {
              return Center(child: CircularProgressIndicator());
            }
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                TextField(
                  controller: emailController,
                  decoration: InputDecoration(labelText: 'Email'),
                ),
                TextField(
                  controller: passwordController,
                  decoration: InputDecoration(labelText: 'Password'),
                  obscureText: true,
                ),
                SizedBox(height: 20),
                ElevatedButton(
                  onPressed: () {
                    context.read<LoginBloc>().add(
                      LoginSubmitted(
                        emailController.text,
                        passwordController.text,
                      ),
                    );
                  },
                  child: Text('Login'),
                ),
                SizedBox(height: 20),
                Text('Try with email: "error@test.com" to see error handling'),
              ],
            );
          },
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

How should you handle an error that occurs inside an event handler?

A
Ignore it and hope it doesn't crash
B
Use try/catch and emit an error state
C
Only log it to the console
D
Rethrow the exception
Q2
of 3

What is the purpose of `BlocObserver`?

A
To observe UI changes
B
To globally observe all BLoC events, transitions, and errors
C
To replace `BlocProvider`
D
To handle state serialization
Q3
of 3

Which method in a BLoC is called when an uncaught exception occurs?

A
onException
B
onError
C
handleError
D
catchError

Frequently Asked Questions

Should I catch errors inside the event handler or rely on `onError`?

Use try/catch inside the handler when you need to emit specific error states. Use onError for logging and global handling of uncaught exceptions.

How do I handle errors when using `emit.forEach`?

Pass an onError callback to emit.forEach. It will catch errors from the stream and let you emit an error state.

Can I emit a state inside `onError`?

Technically yes, but it’s not recommended because the error may occur outside the normal event flow. Instead, handle errors within the handler itself.

How do I test error scenarios in a BLoC?

Use blocTest and mock your dependencies to throw exceptions. Then verify that the expected error state is emitted.

What’s the difference between `BlocObserver.onError` and `Bloc.onError`?

BlocObserver.onError is global and catches errors from all BLoCs. Bloc.onError is specific to a single BLoC and is called after the observer.

Previous

bloc concurrency

Next

bloc global error handler

Related Content

Need help?

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