Lesson 19 of 77 advanced

Advanced BLoC Patterns

Patterns That Separate Junior From Senior BLoC Usage

Open interactive version (quiz + challenge)

Real-world analogy

Basic BLoC is like knowing how to drive a car. Advanced BLoC patterns are knowing when to take the highway vs back roads (event transformers), how to carry multiple passengers (MultiBlocProvider), how to give only the driver vs passenger different information from the dashboard (BlocSelector), and how to coordinate multiple cars in a convoy (BLoC-to-BLoC communication).

What is it?

Advanced BLoC patterns address real production concerns: BlocSelector for precision rebuilds, buildWhen for rebuild control, MultiBlocProvider for clean provision, feature scoping for memory efficiency, BLoC-to-BLoC communication without coupling, event transformers for concurrency control, and HydratedBloc for state persistence.

Real-world relevance

In an NFC asset recovery app, AssetScanBloc (restartable for rapid NFC scans) coordinates with AssetRepositoryBloc via a shared repository, not direct injection. BlocSelector extracts only the scan status for the status indicator widget — avoiding rebuilds when asset details change. HydratedBloc persists the last known asset state for offline-first operation. MultiBlocProvider scopes all three BLoCs to the scan screen's route.

Key points

Code example

// Advanced BLoC Patterns

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';

// ============================================================
// 1. BlocSelector — Precision rebuilds
// ============================================================

class AssetScanState extends Equatable {
  final ScanStatus status;
  final String? assetId;
  final AssetDetails? details;
  final String? errorMessage;

  const AssetScanState({
    required this.status,
    this.assetId,
    this.details,
    this.errorMessage,
  });

  @override
  List<Object?> get props => [status, assetId, details, errorMessage];
}

enum ScanStatus { idle, scanning, found, error }

// This widget ONLY cares about status — rebuilds only when status changes
class ScanStatusIndicator extends StatelessWidget {
  const ScanStatusIndicator({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocSelector<AssetScanBloc, AssetScanState, ScanStatus>(
      selector: (state) => state.status, // Extract only what we need
      builder: (context, status) {
        return switch (status) {
          ScanStatus.idle    => const Text('Ready to scan'),
          ScanStatus.scanning=> const CircularProgressIndicator(),
          ScanStatus.found   => const Icon(Icons.check, color: Colors.green),
          ScanStatus.error   => const Icon(Icons.error, color: Colors.red),
        };
      },
    );
  }
}

// ============================================================
// 2. buildWhen — custom rebuild logic
// ============================================================

class AssetDetailsPanel extends StatelessWidget {
  const AssetDetailsPanel({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<AssetScanBloc, AssetScanState>(
      // Only rebuild when the details object changes
      // Rebuilds are skipped for status-only changes (e.g., re-scanning)
      buildWhen: (previous, current) =>
          previous.details != current.details,
      builder: (context, state) {
        final details = state.details;
        if (details == null) return const SizedBox.shrink();
        return Card(child: Text(details.name));
      },
    );
  }
}

// ============================================================
// 3. MultiBlocProvider — clean, flat provision
// ============================================================

class AssetScanScreen extends StatelessWidget {
  const AssetScanScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider(
          create: (context) => AssetScanBloc(
            repository: context.read<AssetRepository>(),
          ),
        ),
        BlocProvider(
          create: (context) => AssetHistoryBloc(
            repository: context.read<AssetRepository>(),
          )..add(const LoadHistory()), // Immediately load history
        ),
      ],
      child: const _AssetScanView(),
    );
  }
}

// ============================================================
// 4. MultiListener — multiple side effects
// ============================================================

class _AssetScanView extends StatelessWidget {
  const _AssetScanView();

  @override
  Widget build(BuildContext context) {
    return MultiBlocListener(
      listeners: [
        BlocListener<AssetScanBloc, AssetScanState>(
          listenWhen: (prev, curr) =>
              prev.status != curr.status && curr.status == ScanStatus.found,
          listener: (context, state) {
            // Navigate to asset details on successful scan
            Navigator.of(context).pushNamed('/asset', arguments: state.assetId);
          },
        ),
        BlocListener<AssetScanBloc, AssetScanState>(
          listenWhen: (prev, curr) =>
              prev.status != curr.status && curr.status == ScanStatus.error,
          listener: (context, state) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text(state.errorMessage ?? 'Scan failed')),
            );
          },
        ),
      ],
      child: Column(
        children: [
          const ScanStatusIndicator(),
          const AssetDetailsPanel(),
          ElevatedButton(
            onPressed: () =>
                context.read<AssetScanBloc>().add(const StartScan()),
            child: const Text('Scan NFC'),
          ),
        ],
      ),
    );
  }
}

// ============================================================
// 5. Event Transformers — per-event concurrency
// ============================================================

class AssetSearchBloc extends Bloc<AssetSearchEvent, AssetSearchState> {
  final AssetRepository _repository;

  AssetSearchBloc({required AssetRepository repository})
      : _repository = repository,
        super(const AssetSearchIdle()) {

    // restartable: each new query cancels the previous search
    on<SearchQueryChanged>(
      _onSearchQueryChanged,
      transformer: restartable(),
    );

    // droppable: ignore new submits while one is running
    on<SubmitSearch>(
      _onSubmitSearch,
      transformer: droppable(),
    );
  }

  Future<void> _onSearchQueryChanged(
    SearchQueryChanged event,
    Emitter<AssetSearchState> emit,
  ) async {
    if (event.query.isEmpty) {
      emit(const AssetSearchIdle());
      return;
    }

    emit(const AssetSearchLoading());

    // Debounce: wait before hitting API
    await Future.delayed(const Duration(milliseconds: 300));

    // If this was cancelled by restartable, the await exits here
    if (emit.isDone) return;

    try {
      final results = await _repository.searchAssets(event.query);
      emit(AssetSearchResults(results));
    } catch (e) {
      emit(AssetSearchError(e.toString()));
    }
  }

  Future<void> _onSubmitSearch(
    SubmitSearch event,
    Emitter<AssetSearchState> emit,
  ) async {
    // Full search with sorting, filtering — can't be interrupted
    // droppable prevents the user from submitting twice
  }
}

// ============================================================
// 6. HydratedBloc — Persist state across app restarts
// ============================================================

import 'package:hydrated_bloc/hydrated_bloc.dart';

class ThemeBloc extends HydratedBloc<ThemeEvent, ThemeState> {
  ThemeBloc() : super(const ThemeState(isDark: false)) {
    on<ToggleTheme>((event, emit) {
      emit(ThemeState(isDark: !state.isDark));
    });
  }

  // Deserialize from local storage
  @override
  ThemeState fromJson(Map<String, dynamic> json) {
    return ThemeState(isDark: json['isDark'] as bool? ?? false);
  }

  // Serialize to local storage
  @override
  Map<String, dynamic>? toJson(ThemeState state) {
    return {'isDark': state.isDark};
  }
}

// In main():
// WidgetsFlutterBinding.ensureInitialized();
// HydratedBloc.storage = await HydratedStorage.build(
//   storageDirectory: await getApplicationDocumentsDirectory(),
// );

// Placeholder types
abstract class AssetRepository {
  Future<List<String>> searchAssets(String query);
}
class AssetDetails { final String name; AssetDetails({required this.name}); }
class AssetScanBloc extends Bloc<dynamic, AssetScanState> {
  AssetScanBloc({required AssetRepository repository}) : super(const AssetScanState(status: ScanStatus.idle));
}
class AssetHistoryBloc extends Bloc<dynamic, dynamic> {
  AssetHistoryBloc({required AssetRepository repository}) : super(null);
}
class LoadHistory extends AssetSearchEvent { const LoadHistory(); }
sealed class AssetSearchEvent extends Equatable { const AssetSearchEvent(); @override List<Object?> get props => []; }
class SearchQueryChanged extends AssetSearchEvent { final String query; const SearchQueryChanged(this.query); @override List<Object> get props => [query]; }
class SubmitSearch extends AssetSearchEvent { const SubmitSearch(); }
class StartScan extends AssetSearchEvent { const StartScan(); }
sealed class AssetSearchState extends Equatable { const AssetSearchState(); @override List<Object?> get props => []; }
class AssetSearchIdle extends AssetSearchState { const AssetSearchIdle(); }
class AssetSearchLoading extends AssetSearchState { const AssetSearchLoading(); }
class AssetSearchResults extends AssetSearchState { final List<String> results; const AssetSearchResults(this.results); @override List<Object> get props => [results]; }
class AssetSearchError extends AssetSearchState { final String message; const AssetSearchError(this.message); @override List<Object> get props => [message]; }
class ThemeEvent extends Equatable { @override List<Object?> get props => []; }
class ToggleTheme extends ThemeEvent {}
class ThemeState extends Equatable { final bool isDark; const ThemeState({required this.isDark}); @override List<Object> get props => [isDark]; }

Line-by-line walkthrough

  1. 1. BlocSelector extracts only 'status' — widget never rebuilds for details/error changes
  2. 2. buildWhen in AssetDetailsPanel: skip rebuild if only status changed
  3. 3. MultiBlocProvider: flat list of providers vs deeply nested BlocProvider chains
  4. 4. ..add(const LoadHistory()) chained on BlocProvider.create — immediate event dispatch
  5. 5. MultiBlocListener: separate listener for navigation vs separate listener for snackbars
  6. 6. listenWhen: only fire when transitioning TO the specific status
  7. 7. restartable() on SearchQueryChanged: every new char cancels the previous Future
  8. 8. emit.isDone check after delay: detects if this emission was cancelled by restartable
  9. 9. HydratedBloc: fromJson restores last persisted state on app start
  10. 10. toJson: return a serializable map of the state — persisted automatically on emit

Spot the bug

class ProfileScreen extends StatelessWidget {
  const ProfileScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ProfileBloc, ProfileState>(
      builder: (context, state) {
        if (state is ProfileLoaded && state.isNewUser) {
          Navigator.of(context).push(
            MaterialPageRoute(builder: (_) => const OnboardingScreen()),
          );
        }
        return ProfileView(state: state);
      },
    );
  }
}
Need a hint?
What is wrong with navigating inside BlocBuilder's builder?
Show answer
Side effects like navigation MUST NOT be in builder(). builder() can be called multiple times per frame — this would trigger multiple navigation pushes, stacking OnboardingScreen repeatedly. Fix: move navigation to BlocListener. listenWhen: (prev, curr) => curr is ProfileLoaded && curr.isNewUser && prev is! ProfileLoaded prevents re-triggering on subsequent rebuilds.

Explain like I'm 5

BlocSelector is like a sports score app where you only want the basketball score — you don't need to refresh your screen when the football score changes. buildWhen is like a filter that says 'only interrupt me for important messages, not every notification.' MultiBlocProvider is like assigning multiple managers to a project team without creating a tangled org chart. restartable() is like canceling your old pizza order the moment you change your mind about the topping.

Fun fact

The restartable() transformer from bloc_concurrency is what enables search-as-you-type without debounce timers — each new character cancels the previous Dart Future before it completes. Under the hood, it uses Dart's Stream.switchMap. The bloc_concurrency package was created by Felix Angelov specifically to replace complex custom EventTransformer implementations that teams were copy-pasting across projects.

Hands-on challenge

Build a TransactionFilterBloc with: FilterChanged(query, dateRange, minAmount, maxAmount) event, TransactionFilterState(filteredList, query, dateRange). Use BlocSelector in a separate TransactionCountBadge widget to show only the count. Use buildWhen in TransactionList to skip rebuilds when only the UI metadata (not the filtered list) changes.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery