📘 Chapter 8: Advanced BLoC Concepts
This chapter dives into advanced Flutter BLoC techniques that elevate the reliability and responsiveness of your app. We’ll explore three major patterns:
🔍 Key Concepts and Terminologies
Term | Description |
---|---|
Hydrated BLoC | Automatically persists the BLoC state on local storage and restores it on app restart. Ideal for counters, filters, settings, etc. |
Event Transformer (Debounce) | Controls how frequently events are processed. Debounce ensures only the latest event after a pause is handled. |
BlocObserver / onError | Catches errors globally from all BLoC and Cubit classes for unified logging or analytics. |
💡 Real-World Example: User Search with Persistent Counter
We’ll implement a working Flutter app with:
- A Hydrated BLoC counter that survives app restarts.
- A debounced search field that fetches users from the JSONPlaceholder API.
- Centralized error logging with
SimpleBlocObserver
.
📦 Dependencies (pubspec.yaml)
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.3
hydrated_bloc: ^9.1.0
path_provider: ^2.0.0
http: ^0.13.6
rxdart: ^0.27.7
🛠️ main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'bloc/counter_cubit.dart';
import 'bloc/search_bloc.dart';
import 'bloc/simple_bloc_observer.dart';
import 'home_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
HydratedBloc.storage = storage;
Bloc.observer = SimpleBlocObserver();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => CounterCubit()),
BlocProvider(create: (_) => SearchBloc()),
],
child: MaterialApp(
title: 'Advanced BLoC Demo',
home: HomePage(),
),
);
}
}
💾 counter_cubit.dart (Hydrated BLoC)
import 'package:hydrated_bloc/hydrated_bloc.dart';
class CounterCubit extends HydratedCubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
@override
int fromJson(Map<String, dynamic> json) => json['value'] as int;
@override
Map<String, dynamic> toJson(int state) => {'value': state};
}
⏳ search_bloc.dart (With Debounce & API)
import 'dart:convert';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:http/http.dart' as http;
import 'package:rxdart/rxdart.dart';
abstract class SearchEvent {
final String query;
const SearchEvent(this.query);
}
class QueryChanged extends SearchEvent {
QueryChanged(String query) : super(query);
}
class SearchBloc extends Bloc<SearchEvent, List<String>> {
SearchBloc() : super([]) {
on<QueryChanged>(
_onQueryChanged,
transformer: debounce(const Duration(milliseconds: 400)),
);
}
Future<void> _onQueryChanged(QueryChanged event, Emitter<List<String>> emit) async {
try {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users'));
if (response.statusCode == 200) {
final List users = json.decode(response.body);
final results = users
.where((user) =>
user['name'].toLowerCase().contains(event.query.toLowerCase()))
.map<String>((user) => user['name'] as String)
.toList();
emit(results);
} else {
emit(['Failed to fetch users']);
}
} catch (e) {
emit(['Error: $e']);
}
}
EventTransformer<E> debounce<E>(Duration duration) {
return (events, mapper) => events.debounceTime(duration).switchMap(mapper);
}
}
❗ simple_bloc_observer.dart (Error Handling)
import 'package:flutter_bloc/flutter_bloc.dart';
class SimpleBlocObserver extends BlocObserver {
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
print('⚠️ Error in ${bloc.runtimeType}: $error');
super.onError(bloc, error, stackTrace);
}
}
🖼️ home_page.dart (UI)
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'bloc/counter_cubit.dart';
import 'bloc/search_bloc.dart';
class HomePage extends StatelessWidget {
final TextEditingController controller = TextEditingController();
@override
Widget build(BuildContext context) {
final counter = context.watch<CounterCubit>().state;
final searchResults = context.watch<SearchBloc>().state;
return Scaffold(
appBar: AppBar(title: Text('Advanced BLoC Concepts')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text('Counter: $counter', style: TextStyle(fontSize: 24)),
ElevatedButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: Text('Increment Counter'),
),
SizedBox(height: 30),
TextField(
controller: controller,
onChanged: (query) =>
context.read<SearchBloc>().add(QueryChanged(query)),
decoration: InputDecoration(
labelText: 'Search Users...',
border: OutlineInputBorder(),
),
),
SizedBox(height: 20),
Expanded(
child: ListView.builder(
itemCount: searchResults.length,
itemBuilder: (_, index) => ListTile(
title: Text(searchResults[index]),
),
),
),
],
),
),
);
}
}
✅ Summary
Feature | Purpose |
Hydrated BLoC | Saves and restores state across app restarts |
Debounce | Prevents rapid API calls while typing |
Error Handling | Logs BLoC errors globally in one place |