Lesson 35 of 51 advanced

Advanced BLoC Patterns

Battle-tested patterns from the production trenches

Open interactive version (quiz + challenge)

Real-world analogy

A regular chef can cook a meal. But a master chef knows how to handle a kitchen fire, coordinate with five other chefs, manage allergies, and keep the kitchen running when the oven breaks down. Advanced BLoC patterns are the difference between a student project and a production app -- they handle the messy reality of loading indicators, error recovery, safe state emission, and BLoC communication.

What is it?

Advanced BLoC patterns are production-grade techniques that handle real-world complexities. The safeEmit pattern from team_mvp_kit prevents crashes when async operations complete after a BLoC is closed. The loading/success/failure tri-state ensures every async operation has proper UI feedback. Typed Failure classes replace raw error strings. BLoC-to-BLoC communication uses stream subscriptions. Event debouncing prevents excessive processing. BlocObserver enables global debugging.

Real-world relevance

In team_mvp_kit, every BLoC uses safeEmit to prevent emit-after-close crashes. Every async operation follows the loading/success/failure pattern so users always see either a spinner, the data, or a helpful error message. Typed failures let the UI show 'No internet connection, tap to retry' versus 'Server error, please try later.' BlocObserver logs every state transition in development builds, making debugging straightforward.

Key points

Code example

import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

// ---- Failure types (team_mvp_kit pattern) ----

abstract class Failure extends Equatable {
  final String message;
  const Failure(this.message);
  @override
  List<Object?> get props => [message];
}

class ServerFailure extends Failure {
  final int? statusCode;
  const ServerFailure(super.message, {this.statusCode});
  @override
  List<Object?> get props => [message, statusCode];
}

class NetworkFailure extends Failure {
  const NetworkFailure() : super('No internet connection');
}

// ---- SafeEmit mixin (team_mvp_kit pattern) ----

mixin SafeEmitMixin<E, S> on Bloc<E, S> {
  void safeEmit(S newState) {
    if (!isClosed) {
      // ignore: invalid_use_of_visible_for_testing_member
      emit(newState);
    }
  }
}

// ---- Base classes ----

abstract class BaseEvent extends Equatable {
  const BaseEvent();
  @override
  List<Object?> get props => [];
}

abstract class BaseState extends Equatable {
  const BaseState();
  @override
  List<Object?> get props => [];
}

// ---- Feature: Product catalog ----

class Product {
  final String id;
  final String name;
  final double price;
  const Product(this.id, this.name, this.price);
}

// Events
class LoadProducts extends BaseEvent {}
class RefreshProducts extends BaseEvent {}

// State with loading/success/failure
class ProductState extends BaseState {
  final List<Product> products;
  final bool isLoading;
  final Failure? failure;

  const ProductState({
    this.products = const [],
    this.isLoading = false,
    this.failure,
  });

  bool get isSuccess => !isLoading && failure == null;

  ProductState copyWith({
    List<Product>? products,
    bool? isLoading,
    Failure? failure,
  }) {
    return ProductState(
      products: products ?? this.products,
      isLoading: isLoading ?? this.isLoading,
      failure: failure,
    );
  }

  @override
  List<Object?> get props => [products, isLoading, failure];
}

// Repository interface
abstract class ProductRepository {
  Future<List<Product>> getProducts();
}

// BLoC with safeEmit and error handling
class ProductBloc extends Bloc<BaseEvent, ProductState>
    with SafeEmitMixin {
  final ProductRepository _repository;

  ProductBloc({required ProductRepository repository})
      : _repository = repository,
        super(const ProductState()) {
    on<LoadProducts>(_onLoad);
    on<RefreshProducts>(_onRefresh);
  }

  Future<void> _onLoad(
    LoadProducts event,
    Emitter<ProductState> emit,
  ) async {
    safeEmit(state.copyWith(isLoading: true, failure: null));
    try {
      final products = await _repository.getProducts();
      safeEmit(state.copyWith(
        products: products,
        isLoading: false,
      ));
    } catch (e) {
      safeEmit(state.copyWith(
        isLoading: false,
        failure: ServerFailure(e.toString()),
      ));
    }
  }

  Future<void> _onRefresh(
    RefreshProducts event,
    Emitter<ProductState> emit,
  ) async {
    try {
      final products = await _repository.getProducts();
      safeEmit(state.copyWith(products: products));
    } catch (e) {
      safeEmit(state.copyWith(
        failure: ServerFailure(e.toString()),
      ));
    }
  }
}

// ---- BlocObserver ----

class AppBlocObserver extends BlocObserver {
  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    print('${bloc.runtimeType}: '
        '${transition.currentState.runtimeType} -> '
        '${transition.nextState.runtimeType}');
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace st) {
    super.onError(bloc, error, st);
    print('ERROR in ${bloc.runtimeType}: $error');
  }
}

Line-by-line walkthrough

  1. 1. Define an abstract Failure class extending Equatable with a message field for all error types.
  2. 2. ServerFailure adds a statusCode field. NetworkFailure uses a fixed message for connectivity errors.
  3. 3. SafeEmitMixin checks isClosed before calling emit, preventing the common post-close crash.
  4. 4. BaseEvent and BaseState extend Equatable with empty props as the foundation for all events and states.
  5. 5. Product is a simple data class representing catalog items.
  6. 6. LoadProducts triggers initial fetch. RefreshProducts triggers pull-to-refresh without a loading indicator.
  7. 7. ProductState follows the loading/success/failure tri-state pattern with typed Failure.
  8. 8. The isSuccess computed getter returns true when not loading and no failure exists.
  9. 9. copyWith allows creating new states with selective field overrides.
  10. 10. ProductRepository is an abstract interface for testability.
  11. 11. ProductBloc mixes in SafeEmitMixin and takes the repository through constructor injection.
  12. 12. _onLoad emits loading state with failure cleared, then tries to fetch products.
  13. 13. On success, safeEmit pushes the loaded products with loading false.
  14. 14. On failure, the catch block wraps the exception in a ServerFailure.
  15. 15. _onRefresh skips the loading indicator since pull-to-refresh has its own visual feedback.
  16. 16. AppBlocObserver logs every state transition and error for debugging across all BLoCs.

Spot the bug

class ProfileBloc extends Bloc<ProfileEvent, ProfileState>
    with SafeEmitMixin {
  final ProfileRepository _repo;

  ProfileBloc(this._repo) : super(const ProfileState()) {
    on<LoadProfile>(_onLoad);
  }

  Future<void> _onLoad(
    LoadProfile event,
    Emitter<ProfileState> emit,
  ) async {
    safeEmit(state.copyWith(isLoading: true));
    final profile = await _repo.getProfile();
    safeEmit(state.copyWith(
      profile: profile,
      isLoading: false,
    ));
  }
}
Need a hint?
What happens if _repo.getProfile() throws an exception?
Show answer
The _onLoad method has no try-catch block. If the repository throws, the exception is unhandled and the BLoC will be stuck in a loading state forever because the isLoading: false emit never executes. Fix: wrap in try-catch and emit a failure state in the catch block.

Explain like I'm 5

Imagine you are building a really fancy robot. A basic robot just walks forward. But a production robot needs to handle problems: What if it bumps into a wall? (error handling) What if you tell it to stop but it is mid-step? (safeEmit) What if it needs to talk to another robot? (BLoC communication) What if you want to see everything it is thinking for debugging? (BlocObserver) Advanced BLoC patterns are like giving your robot street smarts -- it can handle the messy real world, not just a clean lab.

Fun fact

The 'emit after close' bug that safeEmit prevents is so common that it has its own GitHub issue on the bloc repository with hundreds of thumbs-up reactions. It typically happens when a user navigates away from a screen while an API call is still in progress. The BLoC gets closed by BlocProvider's automatic disposal, but the awaited Future still tries to emit.

Hands-on challenge

Build a robust UserProfileBloc that: (1) Uses the safeEmit mixin, (2) Has a UserProfileState with loading/success/failure fields, (3) Handles three events: LoadProfile, UpdateProfile, and DeleteAccount, (4) Uses typed Failure classes (ServerFailure, NetworkFailure, ValidationFailure), (5) Communicates with an AuthBloc by listening to logout events to clear the profile. Write a complete blocTest for the LoadProfile success and failure cases.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart