Skip to content

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(...),
)

runAwait takes event as a positional parameter (not the named event:), and returns Future<AwaitResolution<State>> rather than a bare Future. Writing Future<void> refresh() => runAwait(...) works because Dart lets you narrow any Future to Future<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

⚠️ AwaitCompleted only means "the handler did not throw" — not that the business operation succeeded. Always judge business success from AwaitCompleted.value (the current state).

Scheduling strategies (AwaitTransformer)

Concurrency is the default. Pass a transformer to onAwait when you need queueing, dropping or restarting:

onAwait<ItemRefresh>(
  _onRefresh,
  transformer: AwaitTransformer.droppable(),
);
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: runAwait does not time out. If a handler may never finish, end it yourself with BlocCancelTokenMixin / a Dio timeout; close() ends the wait normally as AwaitCancelled.
  • When the page is disposed (close()), all unfinished waits complete normally as AwaitCancelled instead of throwing — closing the page right after a pull-to-refresh no longer produces an uncaught error.
  • Merging relies on event value equality: freezed events have it out of the box; hand-written event classes without == / hashCode degrade 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-calls cancelAll(), 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. Override isCancelled when 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));
  }
}
  • BlocAwaitMixin lets the page await refresh(); repeated pulls merge into the same run instead of firing a second request.
  • BlocEffectMixin emits the Toast (failures go through ex.toToastEffect()).
  • BlocErrorHandlerMixin uses runCatching to eliminate try/catch.
  • (Cancel scenario) to abort the request mid-flight, add BlocCancelTokenMixin and pass token('items') to the repository.

Source of this page: docs/en/architecture/bloc-mixins.md

Report an error on this page