BLoC Fundamentals
Events In, States Out — The Pattern That Scales to Any Team
Open interactive version (quiz + challenge)Real-world analogy
What is it?
BLoC (Business Logic Component) is a pattern and library that separates business logic from UI via unidirectional data flow: Events are dispatched to the BLoC, processed by on handlers, and new States are emitted to the UI. BlocObserver provides global visibility into every transition. This auditability and testability make BLoC the standard for complex Flutter features.
Real-world relevance
In a fintech app, the payment feature uses PaymentBloc with events: InitiatePayment, ConfirmPayment, CancelPayment. States: PaymentIdle, PaymentLoading, PaymentConfirmationRequired(details), PaymentSuccess(receipt), PaymentFailed(reason). BlocObserver logs every transition to analytics. BlocListener navigates to a receipt screen on PaymentSuccess. This explicit audit trail satisfies financial compliance requirements.
Key points
- BLoC vs Cubit — The Distinction — BLoC adds a formal Event class on top of Cubit. Events are dispatched with bloc.add(SomeEvent()). Each event type is handled by on((event, emit) => ...). The explicit event model makes it easier to: add event-specific logging, transform events (debounce, throttle), and review every action the user took in BlocObserver.
- Event Classes — The Inputs — Events describe what happened: LoadTransactions, FilterByDate, SubmitPayment, RefreshData. They are immutable value objects. Extend Equatable if you want to deduplicate identical events. Use sealed classes for exhaustive handling: sealed class TransactionEvent {} with subclasses. Each event carries its own data as fields.
- on Handler — The Modern API — Since BLoC 7.2+, use on((event, emit) → ...) in the constructor. This replaces the old mapEventToState generator. Advantages: type-safe (the handler only sees events of that type), supports async via async/await, each event type has its own handler method for clarity.
- The Emitter — Thread-Safe emit() — The Emitter passed to on handlers is not the same as Cubit's emit(). It's thread-safe: multiple concurrent events won't produce race conditions in the state. emit.forEach() processes a stream and emits states for each element. emit.onEach() listens without transforming.
- Side Effects — BlocListener — BlocListener reacts to state changes for SIDE EFFECTS: show dialogs, navigate, display snackbars. Never put side effects in BlocBuilder. BlocListener: fires once per state change. BlocConsumer = BlocBuilder + BlocListener in one widget. BlocSelector: rebuilds only when a specific field in the state changes.
- BlocObserver — Global Visibility — Override BlocObserver and register with Bloc.observer = MyObserver() in main(). Override: onCreate, onEvent, onTransition, onError, onClose. Every BLoC/Cubit in the app routes through here. Use for: Firebase Analytics events, Sentry error reporting, debug logging. Interview: How do you track every user action in Flutter? BlocObserver.
- Transition — The Audit Trail — A Transition contains: currentState, event, nextState. In BlocObserver.onTransition, log these to analytics or debug output. This is unachievable with simple setState or ChangeNotifier — they don't record what triggered the change. This traceability is why senior devs choose BLoC for complex features.
- Error Handling in BLoC — In on handlers, wrap with try/catch. emit the error state. In BlocObserver.onError, log to Crashlytics. The BLoC pattern keeps error handling centralized and visible. Never swallow errors silently — always emit an error state so the UI can show appropriate feedback.
- EventTransformer — Debounce, Throttle, Sequential — on(handler, transformer: eventTransformer) controls how events are processed. droppable(): ignore new events while one is processing (prevent duplicate submissions). restartable(): cancel previous and start new (search as you type). sequential(): queue events. debounce/throttle from bloc_concurrency package.
- Full BLoC Implementation Pattern — Standard structure: (1) sealed event classes with data, (2) sealed state classes with Equatable, (3) BLoC with on<> handlers for each event, (4) repository injected via constructor, (5) BlocProvider scoped to feature, (6) BlocBuilder + BlocListener in UI, (7) blocTest for each event handler.
Code example
// BLoC Fundamentals — Full Implementation
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart';
// ============================================================
// 1. EVENTS — What the user / system does
// ============================================================
sealed class PaymentEvent extends Equatable {
const PaymentEvent();
@override
List<Object?> get props => [];
}
class InitiatePayment extends PaymentEvent {
final String recipientId;
final double amount;
final String note;
const InitiatePayment({
required this.recipientId,
required this.amount,
required this.note,
});
@override
List<Object> get props => [recipientId, amount, note];
}
class ConfirmPayment extends PaymentEvent {
const ConfirmPayment();
}
class CancelPayment extends PaymentEvent {
const CancelPayment();
}
// ============================================================
// 2. STATES — What the UI should render
// ============================================================
sealed class PaymentState extends Equatable {
const PaymentState();
@override
List<Object?> get props => [];
}
class PaymentIdle extends PaymentState {
const PaymentIdle();
}
class PaymentLoading extends PaymentState {
const PaymentLoading();
}
class PaymentConfirmationRequired extends PaymentState {
final String recipientName;
final double amount;
final double fee;
const PaymentConfirmationRequired({
required this.recipientName,
required this.amount,
required this.fee,
});
@override
List<Object> get props => [recipientName, amount, fee];
double get total => amount + fee;
}
class PaymentProcessing extends PaymentState {
const PaymentProcessing();
}
class PaymentSuccess extends PaymentState {
final String transactionId;
final DateTime timestamp;
const PaymentSuccess({
required this.transactionId,
required this.timestamp,
});
@override
List<Object> get props => [transactionId, timestamp];
}
class PaymentFailed extends PaymentState {
final String reason;
final bool isRetryable;
const PaymentFailed({required this.reason, required this.isRetryable});
@override
List<Object> get props => [reason, isRetryable];
}
// ============================================================
// 3. THE BLoC
// ============================================================
class PaymentBloc extends Bloc<PaymentEvent, PaymentState> {
final PaymentRepository _repository;
PaymentBloc({required PaymentRepository repository})
: _repository = repository,
super(const PaymentIdle()) {
// Register handlers for each event type
// droppable(): ignore new events while processing (prevent double-submit)
on<InitiatePayment>(
_onInitiatePayment,
transformer: droppable(),
);
on<ConfirmPayment>(
_onConfirmPayment,
transformer: droppable(), // Prevent double-tap confirm
);
on<CancelPayment>(_onCancelPayment);
}
Future<void> _onInitiatePayment(
InitiatePayment event,
Emitter<PaymentState> emit,
) async {
emit(const PaymentLoading());
try {
final details = await _repository.fetchPaymentDetails(
recipientId: event.recipientId,
amount: event.amount,
);
emit(PaymentConfirmationRequired(
recipientName: details.recipientName,
amount: event.amount,
fee: details.calculatedFee,
));
} on InsufficientFundsException {
emit(const PaymentFailed(
reason: 'Insufficient funds',
isRetryable: false,
));
} catch (e) {
emit(PaymentFailed(
reason: 'Unable to prepare payment: $e',
isRetryable: true,
));
}
}
Future<void> _onConfirmPayment(
ConfirmPayment event,
Emitter<PaymentState> emit,
) async {
final current = state;
if (current is! PaymentConfirmationRequired) return;
emit(const PaymentProcessing());
try {
final result = await _repository.executePayment(
amount: current.amount,
fee: current.fee,
);
emit(PaymentSuccess(
transactionId: result.transactionId,
timestamp: result.timestamp,
));
} catch (e) {
emit(PaymentFailed(
reason: 'Payment failed: $e',
isRetryable: true,
));
}
}
void _onCancelPayment(
CancelPayment event,
Emitter<PaymentState> emit,
) {
emit(const PaymentIdle());
}
}
// ============================================================
// 4. BlocObserver — Global audit trail
// ============================================================
class AppBlocObserver extends BlocObserver {
@override
void onEvent(Bloc bloc, Object? event) {
super.onEvent(bloc, event);
debugPrint('[BLoC] ${bloc.runtimeType} received $event');
// FirebaseAnalytics.logEvent(name: 'bloc_event', parameters: {
// 'bloc': bloc.runtimeType.toString(),
// 'event': event.runtimeType.toString(),
// });
}
@override
void onTransition(Bloc bloc, Transition transition) {
super.onTransition(bloc, transition);
debugPrint('[BLoC] Transition: ${transition.currentState} → ${transition.nextState}');
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
super.onError(bloc, error, stackTrace);
// Crashlytics.recordError(error, stackTrace);
debugPrint('[BLoC] Error in ${bloc.runtimeType}: $error');
}
}
// Register in main():
// void main() {
// Bloc.observer = AppBlocObserver();
// runApp(const MyApp());
// }
// ============================================================
// 5. UI — BlocBuilder + BlocListener (via BlocConsumer)
// ============================================================
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PaymentScreen extends StatelessWidget {
const PaymentScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => PaymentBloc(
repository: context.read<PaymentRepository>(),
),
child: const _PaymentView(),
);
}
}
class _PaymentView extends StatelessWidget {
const _PaymentView();
@override
Widget build(BuildContext context) {
return BlocConsumer<PaymentBloc, PaymentState>(
// listener: side effects — navigate, show snackbar, dialog
listener: (context, state) {
switch (state) {
case PaymentSuccess(:final transactionId):
// Navigate to receipt screen
Navigator.of(context).pushReplacementNamed(
'/receipt',
arguments: transactionId,
);
case PaymentFailed(:final reason, :final isRetryable):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(reason)),
);
default:
break;
}
},
// builder: UI rendering based on state
builder: (context, state) {
return switch (state) {
PaymentIdle() => _PaymentForm(),
PaymentLoading() || PaymentProcessing() =>
const Center(child: CircularProgressIndicator()),
PaymentConfirmationRequired(:final recipientName, :final total) =>
_ConfirmationCard(
recipientName: recipientName,
total: total,
onConfirm: () =>
context.read<PaymentBloc>().add(const ConfirmPayment()),
onCancel: () =>
context.read<PaymentBloc>().add(const CancelPayment()),
),
PaymentSuccess() || PaymentFailed() =>
const SizedBox.shrink(), // Handled by listener
};
},
);
}
}
// Placeholder types
abstract class PaymentRepository {
Future<PaymentDetails> fetchPaymentDetails({required String recipientId, required double amount});
Future<PaymentResult> executePayment({required double amount, required double fee});
}
class PaymentDetails { final String recipientName; final double calculatedFee; PaymentDetails({required this.recipientName, required this.calculatedFee}); }
class PaymentResult { final String transactionId; final DateTime timestamp; PaymentResult({required this.transactionId, required this.timestamp}); }
class InsufficientFundsException implements Exception {}
class _PaymentForm extends StatelessWidget { @override Widget build(BuildContext context) => const Text('Form'); }
class _ConfirmationCard extends StatelessWidget {
final String recipientName; final double total;
final VoidCallback onConfirm; final VoidCallback onCancel;
const _ConfirmationCard({required this.recipientName, required this.total, required this.onConfirm, required this.onCancel});
@override Widget build(BuildContext context) => const Text('Confirmation');
}Line-by-line walkthrough
- 1. Sealed PaymentEvent — all events are documented in one place
- 2. InitiatePayment carries its data as final fields — immutable event
- 3. Sealed PaymentState with Equatable — exhaustive, comparable states
- 4. PaymentBloc constructor: register handlers with on
- 5. droppable() transformer on InitiatePayment — no double submissions
- 6. _onInitiatePayment: emit Loading, fetch details, emit Confirmation or Error
- 7. Specific catch for InsufficientFundsException — typed error handling
- 8. _onConfirmPayment: guard with 'is!' check before proceeding
- 9. AppBlocObserver.onTransition: logs currentState → event → nextState audit trail
- 10. BlocConsumer combines listener (side effects) and builder (UI) in one widget
- 11. Listener handles navigation and snackbars — NOT in builder
- 12. Builder switch handles every state — sealed ensures compiler checks exhaustiveness
Spot the bug
class LoginBloc extends Bloc<LoginEvent, LoginState> {
LoginBloc() : super(LoginInitial()) {
on<LoginSubmitted>((event, emit) async {
emit(LoginLoading());
final user = await authService.login(event.email, event.password);
emit(LoginSuccess(user));
});
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- BLoC Core Concepts (bloclibrary.dev)
- bloc_concurrency Package (pub.dev)
- flutter_bloc Widgets (bloclibrary.dev)
- BLoC Testing (bloclibrary.dev)