Part 8 – Flutter BLoC Tutorial: Advanced Concepts (Hydrated BLoC, Debounce, Error Handling)

Flutter Hydrated Block Pattern

Part 8 – Flutter BLoC Tutorial: Advanced Concepts (Hydrated BLoC, Debounce, Error Handling)

AI Reading

Quick summary of this article

This Flutter tutorial covers advanced BLoC patterns: using Hydrated BLoC to persist state across restarts, debouncing user input to limit API calls, and centralizing error logging with a custom observer. The guide includes a working example with a persistent counter and a debounced user search field.

  • Hydrated BLoC automatically saves state to local storage and restores it when the app restarts, useful for settings, filters, and counters.
  • Debounce with an Event Transformer (via rxdart) ensures only the latest event after a pause (e.g., 400ms) is processed, preventing excessive API requests while typing.
  • A custom BlocObserver (like SimpleBlocObserver) catches errors from all BLoC and Cubit instances globally, simplifying logging and analytics.
  • The demo app uses a persistent counter and a search field that fetches user names from the JSONPlaceholder API, filtering results locally.
  • Required packages include flutter_bloc, hydrated_bloc, path_provider, http, and rxdart; the main setup initializes storage and the observer before running the app.

📘 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

Leave a Reply

Your email address will not be published. Required fields are marked *