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 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: 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.

Manual finalization

class ItemBloc extends Bloc<ItemEvent, ItemState>
    with BlocAwaitMixin<ItemEvent, ItemState> {
  Future<void> refresh() =>
      runAwait(event: const ItemEvent.refresh());

  Future<void> _onRefresh(ItemRefresh event, Emitter<ItemState> emit) async {
    try {
      emit(state.copyWith(isRefreshing: true));
      await _repository.fetch();
    } finally {
      emit(state.copyWith(isRefreshing: false));
      completeAwait(event); // must finalize manually, or RefreshIndicator hangs
    }
  }
}
RefreshIndicator(
  onRefresh: () => context.read<ItemBloc>().refresh(),
  child: ListView.builder(...),
)

onAwait uses try/finally to call completeAwait automatically, avoiding hangs from a forgotten call:

ItemBloc(super.initialState) : super() {
  onAwait<ItemRefresh>(_onRefresh); // event type + handler
}

Future<void> _onRefresh(ItemRefresh event, Emitter<ItemState> emit) async {
  emit(state.copyWith(isRefreshing: true));
  await _repository.fetch();
  emit(state.copyWith(isRefreshing: false));
} // finally is auto-filled by onAwait

Key points:

  • runAwait has a default 30s timeout, preventing permanent hangs if the handler throws or the BLoC is closed. Each waiter keeps its own timer, so a timeout ends only that waiter — others waiting on the same event are unaffected.
  • When the page is disposed (close()), all unfinished waits complete normally 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).
  • For special scheduling such as queueing, dropping or restarting, fall back to the plain on registration and call completeAwait(event) manually at the end of the handler.

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; ExceptionFailure(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, ExceptionFailure(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 (Login)

The four Mixins work together in the login module (full code in Write Your First Feature):

class LoginBloc extends Bloc<LoginEvent, LoginState>
    with
        BlocAwaitMixin<LoginEvent, LoginState>,
        BlocEffectMixin<LoginState>,
        BlocErrorHandlerMixin<LoginState> {
  Future<void> submit() =>
      runAwait(event: const LoginEvent.submit());

  Future<void> _onSubmit(LoginSubmit event, Emitter<LoginState> emit) async {
    emit(state.copyWith(isSubmitting: true));
    emitEffect(const LoadingEffect(show: true));
    final result = await runCatching(
      () => repository.login(username: state.username, password: state.password),
    );
    emitEffect(const LoadingEffect(show: false));
    result.when(
      success: (_) => emitEffect(const ToastEffect(l10nCode: 'loginSuccess')),
      failure: (_) => emitEffect(const ToastEffect(l10nCode: 'loginFailed')),
      cancel: () {},
    );
  }
}
  • BlocAwaitMixin lets the page await submit() then navigate; repeated taps merge into the same run instead of firing a second request.
  • BlocEffectMixin emits Loading / Toast.
  • BlocErrorHandlerMixin uses runCatching to eliminate try/catch.
  • (Cancel scenario) if needed, BlocCancelTokenMixin's token('login') can interrupt the login request.

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

Report an error on this page