BLoC Pattern in Depth: Events, States and Cubit
The BLoC pattern was born at Google to solve a concrete problem: separation
clearly the business logic from the presentation so that both can
be independently tested. Felix Angeli, in collaboration with the team
Google's Flutter formalized the pattern in 2018 — and 2026 flutter_bloc
remains one of the most downloaded Flutter packages, with over 10 million downloads monthly.
BLoC 9 adds native support for sealed classes of Dart 3, transforming pattern matching on states from an optional good practice to a mechanism verified by the compiler. If a state is not handled in the pattern match, the compiler reports it as an error.
What You Will Learn
- The architectural difference between Cubit and BLoC and when to use which
- How to structure sealed classes for events and states in Dart 3
- Error handling with dedicated states and graceful recovery
- Composition of multiple BLoCs with
BlocListener - BLoC Hydration with
hydrated_blocby persistence of the state - Testing with
bloc_test: expect, act, verify
Cubit vs BLoC: The Right Choice
flutter_bloc offers two primitives: Cubit e BLoC.
Cubit and the simplified version: exposes methods that output states directly,
without the intermediary of events. BLoC adds the event layer, rendering
the completely event-driven flow.
Cubit: Simplicity for Direct Logic
// Cubit: ideale per contatori, toggle, UI state semplice
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
// Uso nel widget
class CounterWidget extends StatelessWidget {
const CounterWidget({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CounterCubit, int>(
builder: (context, count) => Column(
children: [
Text('Count: $count'),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: () => context.read<CounterCubit>().decrement(),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => context.read<CounterCubit>().increment(),
),
],
),
],
),
);
}
}
BLoC: Event-Driven for Complex Logic
Use BLoC (instead of Cubit) when you need:
- Audit trail of events (who did what and when)
- Stream transformations (debounce, throttle, switchMap)
- Business logic that depends on multiple sequential events
- Event replay for debugging or testing
Sealed Classes: Events and States Type-Safe
// Feature: carrello e-commerce
// events/cart_event.dart
sealed class CartEvent {}
final class CartItemAdded extends CartEvent {
const CartItemAdded({required this.product, this.quantity = 1});
final Product product;
final int quantity;
}
final class CartItemRemoved extends CartEvent {
const CartItemRemoved({required this.productId});
final String productId;
}
final class CartQuantityUpdated extends CartEvent {
const CartQuantityUpdated({
required this.productId,
required this.quantity,
});
final String productId;
final int quantity;
}
final class CartCleared extends CartEvent {}
final class CartCheckoutStarted extends CartEvent {
const CartCheckoutStarted({required this.paymentMethod});
final PaymentMethod paymentMethod;
}
// states/cart_state.dart
sealed class CartState {}
final class CartInitial extends CartState {}
final class CartLoading extends CartState {}
final class CartLoaded extends CartState {
const CartLoaded({
required this.items,
required this.totalAmount,
});
final List<CartItem> items;
final double totalAmount;
int get itemCount => items.fold(0, (sum, item) => sum + item.quantity);
CartLoaded copyWith({
List<CartItem>? items,
}) {
final newItems = items ?? this.items;
final newTotal = newItems.fold(
0.0,
(sum, item) => sum + (item.product.price * item.quantity),
);
return CartLoaded(items: newItems, totalAmount: newTotal);
}
}
final class CartCheckoutInProgress extends CartState {
const CartCheckoutInProgress({required this.items});
final List<CartItem> items;
}
final class CartCheckoutSuccess extends CartState {
const CartCheckoutSuccess({required this.orderId});
final String orderId;
}
final class CartError extends CartState {
const CartError({required this.message, this.previousState});
final String message;
final CartState? previousState; // Per il recovery
}
The BLoC: Event Management
// bloc/cart_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc({
required CartRepository cartRepository,
required OrderRepository orderRepository,
}) : _cartRepository = cartRepository,
_orderRepository = orderRepository,
super(CartInitial()) {
// Registra gli event handler
on<CartItemAdded>(_onItemAdded);
on<CartItemRemoved>(_onItemRemoved);
on<CartQuantityUpdated>(_onQuantityUpdated);
on<CartCleared>(_onCartCleared);
// Checkout: usa sequential per garantire un checkout alla volta
on<CartCheckoutStarted>(_onCheckoutStarted, transformer: sequential());
}
final CartRepository _cartRepository;
final OrderRepository _orderRepository;
void _onItemAdded(CartItemAdded event, Emitter<CartState> emit) {
final currentState = state;
if (currentState is CartLoaded) {
final existingIndex = currentState.items
.indexWhere((item) => item.product.id == event.product.id);
final List<CartItem> updatedItems;
if (existingIndex >= 0) {
// Prodotto gia nel carrello: aumenta quantita
updatedItems = List.from(currentState.items)
..[existingIndex] = currentState.items[existingIndex].copyWith(
quantity: currentState.items[existingIndex].quantity + event.quantity,
);
} else {
// Nuovo prodotto: aggiungilo alla lista
updatedItems = [
...currentState.items,
CartItem(product: event.product, quantity: event.quantity),
];
}
emit(currentState.copyWith(items: updatedItems));
} else {
// Carrello non ancora caricato: inizializza
emit(CartLoaded(
items: [CartItem(product: event.product, quantity: event.quantity)],
totalAmount: event.product.price * event.quantity,
));
}
}
void _onItemRemoved(CartItemRemoved event, Emitter<CartState> emit) {
if (state is CartLoaded) {
final current = state as CartLoaded;
final updatedItems = current.items
.where((item) => item.product.id != event.productId)
.toList();
if (updatedItems.isEmpty) {
emit(CartInitial());
} else {
emit(current.copyWith(items: updatedItems));
}
}
}
void _onQuantityUpdated(CartQuantityUpdated event, Emitter<CartState> emit) {
if (state is CartLoaded) {
final current = state as CartLoaded;
if (event.quantity <= 0) {
add(CartItemRemoved(productId: event.productId));
return;
}
final updatedItems = current.items
.map((item) => item.product.id == event.productId
? item.copyWith(quantity: event.quantity)
: item)
.toList();
emit(current.copyWith(items: updatedItems));
}
}
void _onCartCleared(CartCleared event, Emitter<CartState> emit) {
emit(CartInitial());
}
Future<void> _onCheckoutStarted(
CartCheckoutStarted event,
Emitter<CartState> emit,
) async {
if (state is! CartLoaded) return;
final loadedState = state as CartLoaded;
emit(CartCheckoutInProgress(items: loadedState.items));
try {
final orderId = await _orderRepository.createOrder(
items: loadedState.items,
paymentMethod: event.paymentMethod,
);
emit(CartCheckoutSuccess(orderId: orderId));
} catch (error) {
emit(CartError(
message: 'Pagamento fallito: $error',
previousState: loadedState,
));
}
}
}
Widget with BlocConsumer: Builder + Listener
// Combina BlocBuilder (rebuild) e BlocListener (side effects)
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override
Widget build(BuildContext context) {
return BlocConsumer<CartBloc, CartState>(
// Listener: side effects (navigation, snackbar, ecc.)
listener: (context, state) {
switch (state) {
case CartCheckoutSuccess(:final orderId):
// Naviga alla pagina di conferma
Navigator.pushReplacementNamed(
context,
'/order-confirmation',
arguments: orderId,
);
case CartError(:final message, :final previousState):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
// Recovery automatico allo stato precedente
if (previousState != null) {
context.read<CartBloc>().emit(previousState);
}
default:
break;
}
},
// Builder: UI reattiva allo stato
builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: const Text('Carrello'),
actions: [
if (state is CartLoaded && state.items.isNotEmpty)
TextButton(
onPressed: () => context.read<CartBloc>().add(CartCleared()),
child: const Text('Svuota'),
),
],
),
body: switch (state) {
CartInitial() => const EmptyCartView(),
CartLoading() => const CircularProgressIndicator(),
CartLoaded(:final items, :final totalAmount) => CartItemsList(
items: items,
totalAmount: totalAmount,
),
CartCheckoutInProgress() => const CheckoutLoadingView(),
CartCheckoutSuccess() => const SizedBox.shrink(), // gestito dal listener
CartError(:final message) => ErrorView(message: message),
},
);
},
);
}
}
BLoC testing with bloc_test
// test/cart_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockOrderRepository extends Mock implements OrderRepository {}
void main() {
late MockOrderRepository mockOrderRepository;
setUp(() {
mockOrderRepository = MockOrderRepository();
});
group('CartBloc', () {
blocTest<CartBloc, CartState>(
'CartItemAdded: emette CartLoaded con il nuovo prodotto',
build: () => CartBloc(
cartRepository: MockCartRepository(),
orderRepository: mockOrderRepository,
),
act: (bloc) => bloc.add(
CartItemAdded(
product: const Product(
id: '1', name: 'Test', description: '', price: 10.0, inStock: true,
),
),
),
expect: () => [
isA<CartLoaded>()
.having((s) => s.items.length, 'items count', 1)
.having((s) => s.totalAmount, 'total', 10.0),
],
);
blocTest<CartBloc, CartState>(
'CartCheckoutStarted: emette InProgress poi Success',
setUp: () {
when(() => mockOrderRepository.createOrder(
items: any(named: 'items'),
paymentMethod: any(named: 'paymentMethod'),
)).thenAnswer((_) async => 'ORDER-123');
},
build: () => CartBloc(
cartRepository: MockCartRepository(),
orderRepository: mockOrderRepository,
),
seed: () => const CartLoaded(
items: [CartItem(product: fakeProduct, quantity: 1)],
totalAmount: 10.0,
),
act: (bloc) => bloc.add(
const CartCheckoutStarted(paymentMethod: PaymentMethod.creditCard),
),
expect: () => [
isA<CartCheckoutInProgress>(),
isA<CartCheckoutSuccess>()
.having((s) => s.orderId, 'orderId', 'ORDER-123'),
],
);
});
}
BLoC Hydration: Persistence between Sessions
// Con hydrated_bloc: lo stato sopravvive al riavvio dell'app
import 'package:hydrated_bloc/hydrated_bloc.dart';
class CartBloc extends HydratedBloc<CartEvent, CartState> {
CartBloc({...}) : super(CartInitial()) {
// ...event handler...
}
@override
CartState? fromJson(Map<String, dynamic> json) {
try {
final items = (json['items'] as List)
.cast<Map<String, dynamic>>()
.map(CartItem.fromJson)
.toList();
return CartLoaded(
items: items,
totalAmount: json['totalAmount'] as double,
);
} catch (_) {
return CartInitial();
}
}
@override
Map<String, dynamic>? toJson(CartState state) {
if (state is CartLoaded) {
return {
'items': state.items.map((i) => i.toJson()).toList(),
'totalAmount': state.totalAmount,
};
}
return null; // Non persiste altri stati
}
}
// Setup in main()
void main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const MyApp());
}
Warning: Evolution of the Persistence Scheme
When you use hydrated_bloc and update the states model (add
or remove fields), manage compatibility with persisted data: a user
who had installed the previous version will have data in the old format.
Always implement a try/catch in the method fromJson()
and returns the initial state in case of deserialization error.
Next Steps
With BLoC mastered, the next article in the series gets to the heart of performance Flutter: Impeller, the new renderer that replaced Skia on Android in 2026, how the rendering pipeline works and how to use Flutter DevTools to identify and fix jank issues.
Conclusions
BLoC 9 with sealed classes is the most robust pattern for enterprise Flutter applications which require traceability, testability and scalability of the team. The cost in terms of boilerplate and justified in contexts where the clarity of the code, the audit trail and predictability are non-negotiable requirements.
Dart 3's sealed classes eliminated the last pain point of the pattern:
the exhaustive pattern match ensures that each state is handled,transforming
a potential runtime bug in a compilation error. Combined with bloc_test
for tests and hydrated_bloc for persistence, BLoC forms an ecosystem
complete and mature.







