Advanced BLoC Patterns
Patterns That Separate Junior From Senior BLoC Usage
Open interactive version (quiz + challenge)Real-world analogy
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
- BlocSelector — Precision Rebuilds — BlocSelector extracts a specific field from the state and only rebuilds when THAT field changes. Example: a UserNameLabel that only shows the user's name — use BlocSelector to extract just the name string. The widget won't rebuild when the user's avatar or email changes. Critical for performance in large state objects.
- buildWhen — Rebuild Control in BlocBuilder — BlocBuilder's buildWhen: (previous, current) => bool controls when the builder runs. Return false to skip. Use case: a header widget that only needs to rebuild when 'isLoading' changes, not every field of the state. Similar to React's shouldComponentUpdate. BlocSelector is cleaner for single-field extraction; buildWhen is for custom logic.
- MultiBlocProvider — Clean Provision — MultiBlocProvider reduces BlocProvider nesting. Instead of BlocProvider(create: ..., child: BlocProvider(create: ..., child: ...)), use MultiBlocProvider(providers: [...], child: ...). Flat and readable. Best practice: create a FeatureProviders widget that wraps the feature screen in all its needed providers.
- BlocListener for Side Effects — The Contract — Rule: NEVER put side effects in BlocBuilder's builder. Side effects (navigation, dialogs, snackbars, haptics) go in BlocListener. BlocListener fires once per state change. BlocConsumer combines both. Multiple BlocListeners: MultiListener. The distinction prevents: duplicate navigations, dialog spam, and rebuild-triggered side effects.
- Feature-Scoped BLoCs — Scope each BLoC to its feature's widget subtree with BlocProvider. Don't put all BLoCs at the app root — they waste memory for features not currently open. Pattern: every feature screen's route wraps itself in its BlocProvider(s). Close them automatically when the route is popped. Interview: Where should you place BlocProvider?
- BLoC-to-BLoC Communication — BLoCs should NOT depend directly on each other. Preferred: (1) Repository/service as shared data layer — both BLoCs use the same repo. (2) A Stream from one BLoC that another listens to via emit.onEach() or StreamSubscription. (3) A parent orchestrator BLoC that coordinates children. Never inject BLoC into BLoC directly.
- Concurrent Event Handling with Transformers — Default: events are processed sequentially. bloc_concurrency package provides: sequential() (default), concurrent() (all run in parallel), droppable() (drop new while running), restartable() (cancel previous, restart with new). Per-event granularity: SearchChanged gets restartable, SubmitForm gets droppable.
- Testing: blocTest Deep Patterns — blocTest: setUp for dependencies, seed for initial state, act for events, expect for states, verify for side effects. Test each event handler independently. Test error paths. Test buildWhen logic manually. Mock the repository. Test that droppable prevents duplicate emissions. Coverage target: every state should be reachable by a test.
- BlocObserver in Production — Register BlocObserver in main() before runApp(). In onEvent: log to analytics (event type + BLoC type). In onTransition: track state machine transitions. In onError: report to Crashlytics with full stackTrace. Add a timing measurement in onCreate/onClose to track BLoC lifetime. This turns your app into a self-documenting state machine.
- Hydrated BLoC — Persistence — HydratedBloc extends Bloc and automatically persists and restores state. Override fromJson(json) and toJson(state) to serialize state. On app restart, the last state is restored before the first build. Used for: user preferences, last viewed content, offline draft. HydratedStorage uses the path_provider directory by default.
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. BlocSelector extracts only 'status' — widget never rebuilds for details/error changes
- 2. buildWhen in AssetDetailsPanel: skip rebuild if only status changed
- 3. MultiBlocProvider: flat list of providers vs deeply nested BlocProvider chains
- 4. ..add(const LoadHistory()) chained on BlocProvider.create — immediate event dispatch
- 5. MultiBlocListener: separate listener for navigation vs separate listener for snackbars
- 6. listenWhen: only fire when transitioning TO the specific status
- 7. restartable() on SearchQueryChanged: every new char cancels the previous Future
- 8. emit.isDone check after delay: detects if this emission was cancelled by restartable
- 9. HydratedBloc: fromJson restores last persisted state on app start
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- BlocSelector (bloclibrary.dev)
- bloc_concurrency (pub.dev)
- hydrated_bloc (pub.dev)
- Testing BLoC (bloclibrary.dev)