bloc_error_control 1.2.2
bloc_error_control: ^1.2.2 copied to clipboard
Declarative error handling for BLoC with Zones and optional code generation
๐ก๏ธ Bloc Error Control #
An advanced error handling and event lifecycle management solution for Flutter applications built with BLoC. The package transforms chaotic asynchronous exceptions into structured states while preventing memory leaks and resource waste.
๐ฏ When to Use This Package #
โ Use it if:
- You have a large project (50+ events)
- You're tired of try-catch boilerplate
- You need automatic request cancellation
- You're ready to learn about Zones
โ Don't use it if:
- You have a small project (less than 10 events)
- You're not familiar with Zones
- You find it easier to write try-catch
๐ฏ Why do you need this? #
The Problem: Typical BLoC code #
on<LoadUser>((event, emit) async {
emit(UserLoading());
try {
final user = await userRepository.getUser(event.id);
emit(UserLoaded(user));
} on SocketException {
emit(UserError('No internet connection'));
} on ApiException catch (e) {
emit(UserError(e.message));
} catch (e, stack) {
logger.error('Unexpected', error: e, stackTrace: stack);
emit(UserError('Something went wrong'));
}
});
Problems:
- 80% of the code is error handling
- Easy to forget handling an exception
- No automatic cancellation of "hanging" requests
The Solution: Your code becomes clean
on<LoadUser>((event, emit) async {
emit(UserLoading());
final user = await userRepository.getUser(event.id, token: contextToken);
emit(UserLoaded(user));
});
That's it! No try-catch. Errors are handled centrally..
๐ฆ Features #
| Feature | Description |
|---|---|
| ๐ซ No try-catch | Event handlers contain only business logic |
| ๐ฏ Centralized error handling | mapErrorToState โ one method for all errors |
| ๐ Auto-cancellation of requests | contextToken automatically cancels requests on repeated events |
๐งน Cleanup on close() |
All active requests are cancelled when the bloc is closed |
| ๐ Flexible logging | Plug any logger (Sentry, Firebase, custom) |
| โก๏ธ Works with any transformers | sequential(), restartable(), debounce() etc. |
| ๐งฌ Optional code generation | Minimal boilerplate through annotations |
๐ Quick start #
class UserBloc extends Bloc<UserEvent, UserState>
with BlocErrorControlMixin<UserEvent, UserState> {
UserBloc() : super(UserInitial()) {
on<LoadUserEvent>(_onLoadUser);
}
@override
UserState? mapErrorToState(Object error, StackTrace stack, UserEvent event) {
// All errors from handlers go here
if (error is SocketException) {
return UserError('No internet connection');
}
return UserError('Something went wrong');
}
Future<void> _onLoadUser(LoadUserEvent event, Emitter<UserState> emit) async {
emit(UserLoading());
final user = await userRepository.getUser(event.id);
emit(UserLoaded(user));
}
}
โก๏ธ Signals (Side Effects) #
Signals are a mechanism for transmitting one-time events from BLoC to UI (showing Snackbar, Toast,
navigation, or triggering an animation).
What problem does this solve? #
In standard BLoC, to display an error notification, developers often add an error field to the State.
This leads to several problems:
- Sticky State: When the screen rebuilds (e.g., on rotation), the Snackbar appears again because the error is still present in the state.
- State Pollution: The state should describe what is on the screen, not what happened once.
Signals operate in a parallel stream: they are not persisted in the state, are delivered once, and are automatically bound to the event context.
Usage in BLoC #
To send a signal, use the emitSignal() method.
You can also configure automatic transformation of errors into signals via mapErrorToSignal.
This signal stream is parallel to state handling: the same error may both update state
via mapErrorToState and emit a one-time signal via mapErrorToSignal.
class UserBloc extends Bloc<UserEvent, UserState> with BlocErrorControlMixin<UserEvent, UserState> {
UserBloc() : super(UserInitial()) {
on<UpdateProfileEvent>((event, emit) async {
await repo.updateUser(event.user);
// Manually send a success signal
emitSignal('Profile updated successfully!');
});
}
@override
Object? mapErrorToSignal(Object error, StackTrace stack, UserEvent? event) {
// For some events you can keep the current state
// and additionally notify the user via a signal
if (event is LikePostEvent) {
return 'Failed to like the post. Please try again later.';
}
return null;
}
}
Handling in UI (BlocSignalListener) #
To listen for signals, use the dedicated BlocSignalListener widget.
Thanks to type support, you can listen to signals from the entire bloc or from specific events.
// Listen only to signals from the UpdateProfileEvent
BlocSignalListener<UserBloc, UpdateProfileEvent>(
onSignal: (context, signal) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(signal.toString())),
);
},
child: ProfileScreen(),
)
Advantages of signals in this library #
- Context-Aware: Thanks to the Zone API, each signal "knows" which event it was sent from. This allows filtering notifications in the UI.
- Auto-cancellation: If an event is cancelled (e.g., via restartable), any signals that haven't been sent from its asynchronous code will be ignored.
- Type Safety: You can pass any objects as signals (strings, sealed classes, DTOs).
Error handling contract #
mapErrorToState, mapErrorToSignal, and BlocObserver are independent channels.
mapErrorToStateis responsible for persistent UI state.mapErrorToSignalis responsible for one-time side effects.BlocObserverremains a global diagnostics/logging channel.
Because of this, the same exception may:
- update the state via
mapErrorToState, - emit a signal via
mapErrorToSignal, - and still be visible in
BlocObserver.
This is intentional: UI reaction and global diagnostics are treated as separate concerns.
Support request cancellation (optional) #
Future<void> _onSearch(SearchEvent event, Emitter<SearchState> emit) async {
emit(SearchLoading());
final results = await searchRepository.search(
event.query,
token: contextToken, // โ automatic cancellation
);
emit(SearchLoaded(results));
}
๐งฌ Code generation (optional) #
If you don't want to write if (event is LoadUserEvent) in mapErrorToState,
or override the getErrorMapperForEvent method:
- Add annotation to the class
import 'package:bloc_error_control/annotations.dart';
@BlocErrorControl() // โ add this
class UserBloc extends Bloc<UserEvent, UserState>
with BlocErrorControlMixin<UserEvent, UserState> {
// ...
}
- Add mapper methods with annotation
@ErrorStateFor<LoadUserEvent>()
UserState? onLoadUserError(Object error, StackTrace stack, LoadUserEvent event) {
return UserError('Failed to load user ${event.id}');
}
@ErrorStateFor<UpdateUserEvent>()
UserState? onUpdateUserError(Object error, StackTrace stack, UpdateUserEvent event) {
return UserError('Update failed');
}
- Run generation
dart run build_runner build --delete-conflicting-outputs
๐ Performance #
| Metric | Result | Description |
|---|---|---|
| Latency | ~49.4 ยตs | Delay from add(event) to state emission |
| Throughput | ~69,000 ev/s | Maximum event processing capacity per second |
| Memory Footprint | 0.02 MB | Additional weight of 20,000 active zones/tokens in RAM |
throwIfCancelled |
~0.006 ยตs | Cost of a single token check inside a loop |
๐ฏ How it works (briefly) #
- Each event runs in its own Zone
- A unique ICancelToken is created in the zone
- If an error occurs, the mixin:
- Finds the appropriate mapper (local โ event-specific โ global)
- Transforms the error into a state
- Logs the error
- On repeated events โ the previous request is cancelled
- When the bloc is closed โ all active requests are cancelled
๐งช Test Coverage #
The package has complete code coverage with tests, including all edge cases and asynchronous scenarios.
| Component | Coverage |
|---|---|
| mixins/ | 100% |
| models/ | 100% |
| annotations/ | 100% |
| exceptions/ | 100% |
| Total | 100% |
Running Tests #
flutter test test/bloc_error_control_mixin/unit/bloc_error_control_test.dart
Branch Coverage
- The core mixin logic has 84.6% branch coverage
Generating Coverage Report:
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
๐ง API Reference #
| Method | Description |
|---|---|
S? mapErrorToState(Object error, StackTrace stack, E event) |
Global error handler |
Optional methods #
| Method | Description |
|---|---|
set logger(ErrorLogger logger) |
Plug your own logger |
bool isGlobalSilent(Object error, StackTrace stack) |
Filter "silent" errors |
ICancelToken get contextToken |
Cancellation token for requests |
void cancelTokensByEventType<T>() |
Cancel all requests of a specific type |
List<Map<String, dynamic>> getActiveTokensInfo() |
Diagnostics of active requests |
on() parameters #
| Parameter | Description |
|---|---|
transformer |
Event transformer (sequential(), restartable(), etc.) |
timeout |
Event timeout (default 30 sec) |
โ ๏ธ Requirements #
- Flutter >= 3.0.0
- flutter_bloc >= 8.0.0
- Dart >= 3.0.0
โจ Key features #
- ๐ก๏ธ Zone-based error isolation: Catches "wild" asynchronous errors (in timers, microtasks) that would otherwise crash the app.
- ๐ Universal Token (
contextToken): Automatic cancellation management (CancelToken) for network requests and heavy computations. - ๐ Near-zero overhead: Processing one event takes less than 1 microsecond.
- ๐งน State cleanliness: Protection against "Zombie Emits" (attempts to change state after the event or Bloc has been closed).
- ๐ค Optional code generation: Automatic creation of error mappers through annotations.
- ๐ Silent errors: Built-in filtering of cancellations (e.g., Dio cancel) that shouldn't change the UI.
โ๏ธ Installation #
Add dependencies to your pubspec.yaml:
dependencies:
bloc_error_control: ^1.2.0
dev_dependencies:
bloc_error_control_generator: ^1.2.0
build_runner: ^2.10.0
๐ Example: Dio Integration for ICancelToken #
For convenient integration with the Dio HTTP client, the package provides the DioCancelTokenX extension, which converts ICancelToken to Dio's CancelToken.
Complete extension code:
extension DioCancelTokenX on ICancelToken {
/// Converts [ICancelToken] to Dio's [CancelToken].
CancelToken toDio() {
final dioToken = CancelToken();
if (isCancelled) {
_safeCancel(dioToken);
return dioToken;
}
unawaited(
whenCancel.then((_) {
_safeCancel(dioToken);
}).catchError((_) {
// Error ignored
}),
);
return dioToken;
}
void _safeCancel(CancelToken dioToken) {
if (!dioToken.isCancelled) {
final reason = this is EventCancelToken ? (this as EventCancelToken).reason : null;
dioToken.cancel(reason);
}
}
}
- Usage
class UserBloc extends Bloc<UserEvent, UserState>
with BlocErrorControlMixin<UserEvent, UserState> {
final Dio _dio = Dio();
Future<void> _onLoadUser(LoadUserEvent event, Emitter<UserState> emit) async {
emit(UserLoading());
final response = await _dio.get(
'https://api.example.com/users/${event.id}',
cancelToken: contextToken.toDio(), // โ conversion
);
emit(UserLoaded(response.data));
}
}
The extension automatically:
- Synchronizes the cancellation state between
ICancelTokenand Dio'sCancelToken - Propagates the cancellation reason for debugging purposes
- Safely handles cases where the token is already cancelled
๐ License #
MIT ยฉ 2025
This software is distributed under the MIT license.