Lesson 18 of 77 intermediate

BLoC Fundamentals

Events In, States Out — The Pattern That Scales to Any Team

Open interactive version (quiz + challenge)

Real-world analogy

BLoC is like a company's formal request system. An employee submits a Request Form (Event) to the department. The department (BLoC) processes it, updates the company database (state), and posts the result on the notice board (Stream). Everyone looking at the notice board (BlocBuilder) sees the latest update. The department keeps a log of every request and result (BlocObserver) — perfect for audits.

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

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. 1. Sealed PaymentEvent — all events are documented in one place
  2. 2. InitiatePayment carries its data as final fields — immutable event
  3. 3. Sealed PaymentState with Equatable — exhaustive, comparable states
  4. 4. PaymentBloc constructor: register handlers with on
  5. 5. droppable() transformer on InitiatePayment — no double submissions
  6. 6. _onInitiatePayment: emit Loading, fetch details, emit Confirmation or Error
  7. 7. Specific catch for InsufficientFundsException — typed error handling
  8. 8. _onConfirmPayment: guard with 'is!' check before proceeding
  9. 9. AppBlocObserver.onTransition: logs currentState → event → nextState audit trail
  10. 10. BlocConsumer combines listener (side effects) and builder (UI) in one widget
  11. 11. Listener handles navigation and snackbars — NOT in builder
  12. 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?
What happens if the login API throws an exception?
Show answer
The exception is unhandled — it will propagate and crash, or be silently caught by BLoC's error handler without emitting an error state. The UI stays in LoginLoading forever. Fix: wrap in try/catch, emit LoginFailure(message) in the catch block so the UI can show an error and the user can retry. Also register error via BlocObserver for Crashlytics logging.

Explain like I'm 5

BLoC is like a company suggestion box system. You write your request on a form (Event) and drop it in the box. The department (BLoC) reads it, does the work, and posts the result on the wall (State). Everyone reading the wall (BlocBuilder) sees the latest status. The manager (BlocObserver) secretly reads every form AND every posted result — keeping a full log. BlocListener is the department secretary who takes special actions (makes phone calls, sends emails) when certain results are posted.

Fun fact

The BLoC pattern was introduced by Felix Angelov at Google I/O 2018 as a way to use reactive streams (RxDart) to separate business logic from UI. The original implementation used Streams and Sinks directly. The modern BLoC library (now in the flutter/packages organization) replaced Streams with a much simpler API while keeping the same architectural principles. Over 50,000 apps on the Play Store use BLoC.

Hands-on challenge

Implement a SearchBloc for a fintech transaction search: events SearchQueryChanged(query), ClearSearch. States: SearchIdle, SearchLoading, SearchResults(transactions), SearchError. Use the restartable() transformer on SearchQueryChanged so each new keystroke cancels the previous search. Write blocTest for: empty query returns idle, valid query returns results, failed API returns error.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery