The Four BLoC Mixins¶
core/bloc/ provides four composable Mixins that solve four common MVI pain points under Flutter. They are orthogonal and can be combined arbitrarily:
| Mixin | Problem solved |
|---|---|
BlocAwaitMixin |
add(Event) is fire-and-forget; the UI cannot await |
BlocCancelTokenMixin |
Network request cancellation / deduplication, bound to the BLoC lifecycle |
BlocEffectMixin |
One-shot side-effect channel (Toast / Loading / Dialog) |
BlocErrorHandlerMixin |
Wraps exceptions into Result<T> (success / failure / cancel); Failure carries the raw Exception |
Unified export: import 'package:flutter_zero_app/core/bloc/bloc.dart';
1. BlocAwaitMixin — Make Events Awaitable¶
add returns void, so the UI cannot await. runAwait returns a Future<AwaitResolution<State>> that completes when the event handler finishes.
The event is the wait identity: you don't write any identifier — the event itself identifies the operation. Triggering the same event again while it is being handled merges the new caller into the same run (single-flight): no duplicate dispatch, no duplicate request (repeated pull-to-refresh or double taps execute only once). Events of the same type with different parameters run independently and each completes its own waiters.
Basic usage¶
Register the handler with onAwait (no manual try/finally finalization) and pass the event itself to runAwait:
class ItemBloc extends Bloc<ItemEvent, ItemState>
with BlocAwaitMixin<ItemEvent, ItemState> {
ItemBloc() : super(const ItemState()) {
onAwait<ItemRefresh>(_onRefresh);
}
/// Only want to wait for completion? Narrow it to Future<void>.
Future<void> refresh() => runAwait(const ItemEvent.refresh());
Future<void> _onRefresh(ItemRefresh event, Emitter<ItemState> emit) async {
emit(state.copyWith(isRefreshing: true));
await _repository.fetch();
emit(state.copyWith(isRefreshing: false));
}
}
RefreshIndicator(
onRefresh: () => context.read<ItemBloc>().refresh(),
child: ListView.builder(...),
)
runAwaittakeseventas a positional parameter (not the namedevent:), and returnsFuture<AwaitResolution<State>>rather than a bareFuture. WritingFuture<void> refresh() => runAwait(...)works because Dart lets you narrow anyFuturetoFuture<void>.
Inspecting the outcome¶
When you need to know whether it actually succeeded, keep the AwaitResolution and narrow it with switch:
Future<AwaitResolution<ItemState>> refresh() =>
runAwait(const ItemEvent.refresh());
// Caller side
final r = await bloc.refresh();
switch (r) {
case AwaitCompleted<ItemState>(:final value):
// Handler finished without throwing. NOTE: this does **not** mean the
// business operation succeeded — judge that from `value` (the emitted state).
case AwaitErrored<ItemState>(:final error):
// Handler threw; or add() failed before dispatch (no handler / bloc closed).
case AwaitDropped<ItemState>():
// Dropped or superseded by the transformer; the handler never ran.
case AwaitCancelled<ItemState>():
// Bloc was closed, so the wait was abandoned.
}
All four outcomes belong to the sealed class AwaitResolution<T>, so the compiler checks your switch for exhaustiveness:
| Outcome | Meaning | Layer |
|---|---|---|
AwaitCompleted |
Handler finished without throwing (value = the emitted state) |
execution |
AwaitErrored |
Handler threw, or add() failed before dispatch |
execution |
AwaitDropped |
Dropped / superseded by the transformer; handler never ran | scheduling |
AwaitCancelled |
Bloc closed, so the wait was abandoned | lifecycle |
⚠️
AwaitCompletedonly means "the handler did not throw" — not that the business operation succeeded. Always judge business success fromAwaitCompleted.value(the current state).
Scheduling strategies (AwaitTransformer)¶
Concurrency is the default. Pass a transformer to onAwait when you need queueing, dropping or restarting:
| Factory | Behavior |
|---|---|
AwaitTransformer.sequential() |
Run one after another (queue) |
AwaitTransformer.concurrent() |
Run concurrently (default semantics) |
AwaitTransformer.droppable() |
Drop new events while busy |
AwaitTransformer.restartable() |
New event supersedes the running one |
All four factories live under the AwaitTransformer abstract class, so typing AwaitTransformer. in the IDE reveals every option — no need to import concrete implementations. The type argument E is inferred from the onAwait call site.
Key points:
- No timeout:
runAwaitdoes not time out. If a handler may never finish, end it yourself withBlocCancelTokenMixin/ a Dio timeout;close()ends the wait normally asAwaitCancelled. - When the page is disposed (
close()), all unfinished waits complete normally asAwaitCancelledinstead of throwing — closing the page right after a pull-to-refresh no longer produces an uncaught error. - Merging relies on event value equality:
freezedevents have it out of the box; hand-written event classes without==/hashCodedegrade to no merging (each trigger runs on its own, no error).
2. BlocCancelTokenMixin — Automatic Network Cancellation¶
Manages Dio CancelToken by operation key; auto-cancels on page dispose and auto-dedupes on consecutive calls.
class ItemBloc extends Bloc<ItemEvent, ItemState>
with BlocCancelTokenMixin<ItemState> {
Future<void> _onFetch(ItemFetch event, Emitter<ItemState> emit) async {
try {
final items = await _repository.fetchItems(
cancelToken: token('items'), // one line, fully automatic
);
emit(state.copyWith(items: items));
} catch (e) {
if (isCancelled(e)) return; // active cancel → silent, no need to import Dio
final ex = e is Exception ? e : Exception(e.toString());
emitEffect(ex.toToastEffect());
}
}
void onCancelTapped() => cancelAll(); // cancel button interrupts all in-flight requests
}
Key points:
- Dedupe: each call to
token('items')cancels the previous same-key request before creating a new token. - Lifecycle binding:
close()auto-callscancelAll(), so no stale response arrives after the page pops. - Key isolation: different keys (
'items'/'upload') don't interfere; use different keys for concurrent same-kind requests. - The default key is
'default', suitable for simple BLoCs with a single operation type.
3. BlocEffectMixin — One-shot Side-effects¶
Provides effectStream and emitEffect; side-effects travel through an independent Stream and do not pollute State.
class ItemBloc extends Bloc<ItemEvent, ItemState>
with BlocEffectMixin<ItemState> {
Future<void> _onSubmit(ItemSubmit event, Emitter<ItemState> emit) async {
emitEffect(const LoadingEffect(show: true)); // show Loading
// ... business logic ...
emitEffect(const LoadingEffect(show: false)); // hide Loading
emitEffect(const ToastEffect(l10nCode: 'saved'));
}
}
When the BLoC is closed, the Mixin automatically close()s the controller — no leak. UIEffect is an open base class; a custom type only needs to extends UIEffect (see Effect & Notifiers).
4. BlocErrorHandlerMixin — Unified Error Handling¶
Wraps exceptions from low-level async operations (Dio, etc.) into Result<T> (success / failure / cancel). Failure carries the raw Exception — the framework makes no business or message assumption; the BLoC decides the toast text (see Error Handling & Result).
Preferred: runCatching¶
Replaces hand-written try/catch, with three explicit states:
class ItemBloc extends Bloc<ItemEvent, ItemState>
with BlocEffectMixin<ItemState>, BlocErrorHandlerMixin<ItemState> {
Future<void> _onFetch(ItemFetch event, Emitter<ItemState> emit) async {
final result = await runCatching(() => _repository.fetchItems());
result.when(
success: (items) => emit(state.copyWith(items: items)),
failure: (ex) => emitEffect(ex.toToastEffect()),
cancel: () {/* active cancel, usually do nothing */},
);
}
}
runCatching mapping: success → Success; Exception → Failure(e) (kept as-is); active cancel → Cancel; other errors → Failure(Exception('unknown exception')).
Capability list¶
| Method | Purpose |
|---|---|
runCatching<T>(action) |
Wrap an async action, return Result<T>; cancel → Cancel, Exception → Failure(e), other → Failure(Exception('unknown exception')) |
isCancelled(error) |
Check whether it is an active cancel (Dio cancel exception), to avoid showing a Toast |
Cancel detection defaults to Dio's
DioExceptionType.cancel. OverrideisCancelledwhen using another HTTP client.
Combination Example (List Refresh)¶
The four Mixins work together for a "pull-to-refresh + error toast" flow (full code in Write Your First Feature):
class ItemBloc extends Bloc<ItemEvent, ItemState>
with
BlocAwaitMixin<ItemEvent, ItemState>,
BlocEffectMixin<ItemState>,
BlocErrorHandlerMixin<ItemState> {
ItemBloc({required this.repository}) : super(const ItemState()) {
onAwait<ItemRefresh>(_onRefresh);
}
final ItemRepository repository;
Future<void> refresh() => runAwait(const ItemEvent.refresh());
Future<void> _onRefresh(ItemRefresh event, Emitter<ItemState> emit) async {
emit(state.copyWith(isRefreshing: true));
final result = await runCatching(() => repository.fetchItems());
result.when(
success: (items) => emit(state.copyWith(items: items)),
failure: (ex) => emitEffect(ex.toToastEffect()),
cancel: () {}, // explicit cancellation — usually nothing to do
);
emit(state.copyWith(isRefreshing: false));
}
}
BlocAwaitMixinlets the pageawait refresh(); repeated pulls merge into the same run instead of firing a second request.BlocEffectMixinemits the Toast (failures go throughex.toToastEffect()).BlocErrorHandlerMixinusesrunCatchingto eliminatetry/catch.- (Cancel scenario) to abort the request mid-flight, add
BlocCancelTokenMixinand passtoken('items')to the repository.
Source of this page: docs/en/architecture/bloc-mixins.md