BLoC Pattern in Profondita: Events, States e Cubit
Il pattern BLoC nasce in Google per risolvere un problema concreto: separare
nettamente la logica di business dalla presentazione in modo che entrambe possano
essere testate indipendentemente. Felix Angeli, in collaborazione con il team
Flutter di Google, ha formalizzato il pattern nel 2018 — e nel 2026 flutter_bloc
rimane uno dei pacchetti Flutter piu scaricati, con oltre 10 milioni di download mensili.
BLoC 9 aggiunge il supporto nativo alle sealed classes di Dart 3, trasformando il pattern match sugli stati da una buona pratica opzionale a un meccanismo verificato dal compilatore. Se uno stato non viene gestito nel pattern match, il compilatore lo segnala come errore.
Cosa Imparerai
- La differenza architetturale tra Cubit e BLoC e quando usare quale
- Come strutturare sealed classes per events e states in Dart 3
- Gestione degli errori con stati dedicati e recupero graceful
- Composizione di BLoC multipli con
BlocListener - BLoC Hydration con
hydrated_blocper persistenza dello stato - Testing con
bloc_test: expect, act, verify
Cubit vs BLoC: La Scelta Giusta
flutter_bloc offre due primitive: Cubit e BLoC.
Cubit e la versione semplificata: espone metodi che emettono stati direttamente,
senza l'intermediario degli eventi. BLoC aggiunge il layer degli eventi, rendendo
il flusso completamente event-driven.
Cubit: Semplicita per Logica Diretta
// 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 per Logica Complessa
Usa BLoC (invece di Cubit) quando hai bisogno di:
- Audit trail degli eventi (chi ha fatto cosa e quando)
- Trasformazioni degli stream (debounce, throttle, switchMap)
- Logica di business che dipende da eventi multipli in sequenza
- Event replay per debugging o testing
Sealed Classes: Events e 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
}
Il BLoC: Gestione degli Events
// 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 con 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),
},
);
},
);
}
}
Testing BLoC con 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: Persistenza tra Sessioni
// 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());
}
Attenzione: Evoluzione dello Schema di Persistenza
Quando usi hydrated_bloc e aggiorni il modello degli stati (aggiungi
o rimuovi campi), gestisci la compatibilita con il dato persistito: un utente
che aveva installato la versione precedente avra dati nel vecchio formato.
Implementa sempre un try/catch nel metodo fromJson()
e ritorna lo stato iniziale in caso di errore di deserializzazione.
Next Steps
Con BLoC padroneggiato, il prossimo articolo della serie entra nel cuore delle performance Flutter: Impeller, il nuovo renderer che ha sostituito Skia su Android nel 2026, come funziona la pipeline di rendering e come usare Flutter DevTools per identificare e correggere i problemi di jank.
Conclusioni
BLoC 9 con sealed classes e il pattern piu solido per applicazioni Flutter enterprise che richiedono tracciabilita, testabilita e scalabilita del team. Il costo in termini di boilerplate e giustificato in contesti dove la chiarezza del codice, l'audit trail e la predictability sono requisiti non negoziabili.
Le sealed classes di Dart 3 hanno eliminato l'ultimo punto dolente del pattern:
il pattern match esaustivo garantisce che ogni stato venga gestito, trasformando
un potenziale bug runtime in un errore di compilazione. Combinato con bloc_test
per i test e hydrated_bloc per la persistenza, BLoC forma un ecosistema
completo e maturo.







