Advanced State Management (Bloc & Riverpod)

Defining Events and States in BLoC

16 min Lesson 4 of 14

Defining Events and States in BLoC

The heart of the BLoC pattern lies in two carefully designed class hierarchies: Events and States. Events represent every possible user action or system trigger — they are the inputs to your BLoC. States represent every possible condition the UI can be in — they are the outputs. Designing these contracts correctly is the most important architectural decision you make when adopting BLoC.

In this lesson you will learn how to model both hierarchies using sealed (or abstract) base classes with concrete subclasses, how to leverage Equatable to make states comparable, and how to apply these concepts to a realistic product cart feature.

Note: Sealed classes (available in Dart 3+) are the preferred way to define BLoC events and states because the Dart compiler enforces exhaustive switch coverage. If you are on Dart 2, use abstract class with @immutable instead — the pattern is identical, but exhaustiveness is not compiler-enforced.

Why Separate Events and States?

A common mistake is to store mutable flags inside a BLoC and toggle them directly. This breaks the unidirectional data-flow guarantee. By separating input (events) from output (states) you gain:

  • Traceability — every UI change can be traced back to a specific event.
  • Testability — you can feed a sequence of events into a BLoC in a test and assert the exact sequence of emitted states.
  • Immutability — states are value objects; no widget can mutate them directly.
  • Exhaustive UI rendering — sealed states make it impossible to forget a UI branch in a switch.

Modelling Events as a Sealed Class Hierarchy

Each event class represents one distinct action. The sealed base class declares the contract; concrete subclasses carry the data payload for that action.

CartEvent hierarchy (Dart 3 sealed class)

import 'package:equatable/equatable.dart';

// Base — no instances created directly
sealed class CartEvent extends Equatable {
  const CartEvent();

  @override
  List<Object?> get props => [];
}

// User tapped "Add to cart"
final class CartItemAdded extends CartEvent {
  final Product product;
  final int quantity;

  const CartItemAdded({required this.product, required this.quantity});

  @override
  List<Object?> get props => [product, quantity];
}

// User tapped the remove icon on a cart row
final class CartItemRemoved extends CartEvent {
  final String productId;

  const CartItemRemoved({required this.productId});

  @override
  List<Object?> get props => [productId];
}

// User tapped "Update quantity" spinner
final class CartItemQuantityChanged extends CartEvent {
  final String productId;
  final int newQuantity;

  const CartItemQuantityChanged({
    required this.productId,
    required this.newQuantity,
  });

  @override
  List<Object?> get props => [productId, newQuantity];
}

// User tapped "Clear cart"
final class CartCleared extends CartEvent {
  const CartCleared();
}
Tip: Name events in the past tense from the user's perspective — CartItemAdded, not AddCartItem. This makes your event log read like a history of actions, which is invaluable when debugging or replaying events.

Modelling States as Immutable Equatable Classes

States describe what the UI should render at any moment. Use Equatable so that BLoC can skip emitting a duplicate state (preventing needless rebuilds). Mark every field final — states must never be mutated after construction. Use copyWith to derive a modified state.

CartState hierarchy with Equatable and copyWith

import 'package:equatable/equatable.dart';

// Shared data holder — not itself a state
class CartItem extends Equatable {
  final Product product;
  final int quantity;

  const CartItem({required this.product, required this.quantity});

  CartItem copyWith({Product? product, int? quantity}) => CartItem(
        product: product ?? this.product,
        quantity: quantity ?? this.quantity,
      );

  @override
  List<Object?> get props => [product, quantity];
}

// ── Sealed state hierarchy ─────────────────────────────────────
sealed class CartState extends Equatable {
  const CartState();

  @override
  List<Object?> get props => [];
}

// Cart has not been loaded yet (initial screen render)
final class CartInitial extends CartState {
  const CartInitial();
}

// Cart is being loaded from local storage / remote
final class CartLoading extends CartState {
  const CartLoading();
}

// Cart loaded successfully — holds the full item list
final class CartLoaded extends CartState {
  final List<CartItem> items;

  const CartLoaded({required this.items});

  // Derived helpers — computed from immutable data
  int get totalItems => items.fold(0, (sum, item) => sum + item.quantity);
  double get totalPrice =>
      items.fold(0.0, (sum, item) => sum + item.product.price * item.quantity);

  CartLoaded copyWith({List<CartItem>? items}) =>
      CartLoaded(items: items ?? this.items);

  @override
  List<Object?> get props => [items];
}

// A cart operation failed (network error, stock check, etc.)
final class CartError extends CartState {
  final String message;

  const CartError({required this.message});

  @override
  List<Object?> get props => [message];
}

Why Equatable Matters for States

By default, two Dart objects are equal only if they are the same instance. Without Equatable, emitting a new CartLoaded object with identical items would still trigger a widget rebuild — Flutter would see two different object references. Equatable overrides == and hashCode based on the props list, so BLoC correctly suppresses duplicate emissions.

Warning: If you include a mutable List or Map directly in props, equality checks may behave unexpectedly because Dart compares list identity by default. Wrap mutable collections in a const [] or use a deep-equality package, or store items as an UnmodifiableListView.

Wiring Events and States into a BLoC

With the event and state hierarchies defined, the BLoC class simply maps each event to a new state using on<EventType> handlers. The exhaustive switch over the sealed state ensures no UI branch is forgotten.

CartBloc — connecting events to states

import 'package:flutter_bloc/flutter_bloc.dart';

class CartBloc extends Bloc<CartEvent, CartState> {
  CartBloc() : super(const CartInitial()) {
    on<CartItemAdded>(_onItemAdded);
    on<CartItemRemoved>(_onItemRemoved);
    on<CartItemQuantityChanged>(_onQuantityChanged);
    on<CartCleared>(_onCartCleared);
  }

  void _onItemAdded(CartItemAdded event, Emitter<CartState> emit) {
    final current = state;
    if (current is! CartLoaded) return;

    final existingIndex =
        current.items.indexWhere((i) => i.product.id == event.product.id);

    List<CartItem> updated;
    if (existingIndex >= 0) {
      updated = List.of(current.items)
        ..[existingIndex] = current.items[existingIndex].copyWith(
          quantity: current.items[existingIndex].quantity + event.quantity,
        );
    } else {
      updated = [
        ...current.items,
        CartItem(product: event.product, quantity: event.quantity),
      ];
    }

    emit(current.copyWith(items: updated));
  }

  void _onItemRemoved(CartItemRemoved event, Emitter<CartState> emit) {
    final current = state;
    if (current is! CartLoaded) return;
    emit(current.copyWith(
      items: current.items
          .where((i) => i.product.id != event.productId)
          .toList(),
    ));
  }

  void _onQuantityChanged(
      CartItemQuantityChanged event, Emitter<CartState> emit) {
    final current = state;
    if (current is! CartLoaded) return;
    emit(current.copyWith(
      items: current.items
          .map((i) => i.product.id == event.productId
              ? i.copyWith(quantity: event.newQuantity)
              : i)
          .toList(),
    ));
  }

  void _onCartCleared(CartCleared event, Emitter<CartState> emit) {
    emit(const CartLoaded(items: []));
  }
}

Summary

Well-designed events and states are the foundation of a maintainable BLoC architecture:

  • Use a sealed base class for events and states so the compiler enforces exhaustive handling.
  • Name events in the past tense (action already dispatched) and states as nouns describing the current condition.
  • Extend Equatable and list every meaningful field in props to prevent duplicate-state rebuilds.
  • Keep states immutable — derive new states with copyWith rather than mutating fields.
  • The BLoC class itself stays thin: it only maps event types to state transitions.