← Articles

Flutter BLoC pattern explained with real examples

By Ann Tech · 3 January 2025

BLoC (Business Logic Component) separates presentation from business logic using streams. It's the pattern behind the flutter_bloc package — one of the most widely used state management solutions in production Flutter apps.

Core concepts

  • Event: something that happens (user action, data received)
  • State: what the UI should show at any given moment
  • Bloc: takes events, produces states

The UI dispatches events and rebuilds based on states — it never contains business logic.

A real example: products list

// events.dart
sealed class ProductsEvent {}

class ProductsLoadRequested extends ProductsEvent {}

class ProductsRefreshRequested extends ProductsEvent {}

class ProductsSearchChanged extends ProductsEvent {
  final String query;
  ProductsSearchChanged(this.query);
}
// state.dart
sealed class ProductsState {}

class ProductsInitial extends ProductsState {}

class ProductsLoading extends ProductsState {}

class ProductsLoaded extends ProductsState {
  final List<Product> products;
  final String searchQuery;
  ProductsLoaded({required this.products, this.searchQuery = ''});

  List<Product> get filtered => searchQuery.isEmpty
      ? products
      : products.where((p) =>
          p.name.toLowerCase().contains(searchQuery.toLowerCase())
        ).toList();
}

class ProductsError extends ProductsState {
  final String message;
  ProductsError(this.message);
}
// bloc.dart
class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
  final ProductRepository _repo;

  ProductsBloc(this._repo) : super(ProductsInitial()) {
    on<ProductsLoadRequested>(_onLoad);
    on<ProductsRefreshRequested>(_onRefresh);
    on<ProductsSearchChanged>(
      _onSearchChanged,
      transformer: debounce(const Duration(milliseconds: 300)),
    );
  }

  Future<void> _onLoad(
    ProductsLoadRequested event,
    Emitter<ProductsState> emit,
  ) async {
    emit(ProductsLoading());
    try {
      final products = await _repo.getProducts();
      emit(ProductsLoaded(products: products));
    } catch (e) {
      emit(ProductsError(e.toString()));
    }
  }

  Future<void> _onRefresh(
    ProductsRefreshRequested event,
    Emitter<ProductsState> emit,
  ) async {
    // Keep showing current products while refreshing
    try {
      final products = await _repo.getProducts(forceRefresh: true);
      emit(ProductsLoaded(products: products));
    } catch (e) {
      // Show error but keep current state for retry
      if (state is ProductsLoaded) {
        // Optionally emit a snackbar event via separate stream
      }
    }
  }

  void _onSearchChanged(
    ProductsSearchChanged event,
    Emitter<ProductsState> emit,
  ) {
    if (state is ProductsLoaded) {
      final current = state as ProductsLoaded;
      emit(ProductsLoaded(
        products: current.products,
        searchQuery: event.query,
      ));
    }
  }
}

Providing the BLoC

BlocProvider(
  create: (context) => ProductsBloc(context.read<ProductRepository>())
    ..add(ProductsLoadRequested()),
  child: const ProductsScreen(),
)

Building the UI

class ProductsScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Products'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () => context.read<ProductsBloc>().add(ProductsRefreshRequested()),
          ),
        ],
      ),
      body: Column(
        children: [
          TextField(
            onChanged: (q) => context.read<ProductsBloc>().add(ProductsSearchChanged(q)),
            decoration: const InputDecoration(
              prefixIcon: Icon(Icons.search),
              hintText: 'Search products...',
            ),
          ),
          Expanded(
            child: BlocBuilder<ProductsBloc, ProductsState>(
              builder: (context, state) {
                return switch (state) {
                  ProductsInitial() => const SizedBox.shrink(),
                  ProductsLoading() => const Center(child: CircularProgressIndicator()),
                  ProductsLoaded(:final filtered) => filtered.isEmpty
                      ? const Center(child: Text('No products found'))
                      : ListView.builder(
                          itemCount: filtered.length,
                          itemBuilder: (_, i) => ProductCard(product: filtered[i]),
                        ),
                  ProductsError(:final message) => Center(
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Text(message),
                          ElevatedButton(
                            onPressed: () => context.read<ProductsBloc>().add(ProductsLoadRequested()),
                            child: const Text('Retry'),
                          ),
                        ],
                      ),
                    ),
                };
              },
            ),
          ),
        ],
      ),
    );
  }
}

BlocListener for side effects

For navigation, snackbars, and other one-time effects:

BlocListener<OrderBloc, OrderState>(
  listenWhen: (prev, current) => current is OrderSuccess,
  listener: (context, state) {
    if (state is OrderSuccess) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Order ${state.orderId} placed!')),
      );
      context.go('/orders/${state.orderId}');
    }
  },
  child: const OrderCheckoutForm(),
)

Debounce transformer

EventTransformer<T> debounce<T>(Duration duration) {
  return (events, mapper) => events.debounceTime(duration).switchMap(mapper);
}

// Use in Bloc:
on<ProductsSearchChanged>(
  _onSearchChanged,
  transformer: debounce(const Duration(milliseconds: 300)),
);

Common pitfalls

Emitting state in the wrong order. Always emit the loading state before starting async work. If you forget, the UI shows the previous state until the request completes.

Business logic in BlocBuilder. The builder should only build widgets. BlocBuilder that calls APIs or modifies state is a code smell — move that logic into the Bloc.

One giant BLoC for an entire screen. As features grow, one BLoC with 15 events becomes hard to reason about. Split by feature or domain entity: CartBloc, ProductsBloc, CheckoutBloc.

Sign in to like, dislike, or report.

Flutter BLoC pattern explained with real examples — ANN Tech