← Back to list

Navigating Complexity: Why BLoC’s Verbosity Saved My Sanity on Large Flutter Apps

I remember this project, a pretty standard e-commerce flow initially. You know, product listings, a cart, checkout. Simple enough. We…

Harsh Kumar Khatri · 2026-03-11 10:30 · 0 claps · 7.7 min read paywalled
#flutter #bloc #cubit #state-management #mobile-development
Open on Medium ↗
Wiki topics: BIZ · Business Strategy 📱 · Mobile Development 📊 · Economic Policy

Navigating Complexity: Why BLoC’s Verbosity Saved My Sanity on Large Flutter Apps

I remember this project, a pretty standard e-commerce flow initially. You know, product listings, a cart, checkout. Simple enough. We started with Cubit for our state management — it’s lean, it’s intuitive, and for isolated screens with straightforward async operations, it’s a dream. A few emit calls here and there, and our UI was reacting beautifully.

Then came the “enhancements.” The checkout process wasn’t just a single form anymore. It became a multi-step beast: shipping address validation with a backend lookup, payment method selection (and dynamic fields based on type), loyalty points application, a promotional code entry with real-time validation, and finally, order confirmation with stock checks. Each step had its own loading states, error states, and dependencies on previous steps. Oh, and did I mention the user could jump back and forth between steps?

Our neat little CheckoutCubit, which once proudly held a handful of methods, started to bloat. We had methods like validateShippingAddress(), applyPromoCode(), selectPaymentMethod(), each calling emit multiple times to reflect intermediate states. The problem wasn't just the sheer number of lines; it was the implicit nature of state transitions. When I saw CheckoutState.loading, I had no idea why it was loading. Was it fetching shipping options? Validating a promo? Submitting the final order? Debugging became a nightmare. A bug where a promo code would occasionally fail to apply after a payment method change took two days to track down because the sequence of emit calls wasn't always what we expected, leading to a stale state being used in a subsequent network call.

The Heart of the Matter: Cubit’s Simplicity vs. BLoC’s Structure

Let’s peel back the layers and understand the fundamental difference. Both BLoC and Cubit are built on Dart’s Streams, providing a way for your UI to react to data changes. The difference lies in their approach to how these changes are triggered and managed.

Cubit: The Direct Stream Emitter

A Cubit is essentially a simplified BLoC. It exposes a single method, emit(State newState), which adds a new state to its internal stream. Your UI, listening via BlocBuilder or BlocListener, rebuilds or reacts whenever a new state is emitted. This directness is its greatest strength. Methods in your Cubit encapsulate business logic and directly call emit.

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
}

This is beautifully simple. For a counter, a toggle, or fetching a single list, Cubit is often optimal. No ambiguity: increment() calls emit(state + 1). Clear, concise, and easy to reason about.

BLoC: The Event-Driven State Machine

A BLoC introduces an additional layer: Events. Instead of calling emit directly, you add an Event to the BLoC. The BLoC processes this event through dedicated event handlers, which are then responsible for emitting new states. Think of it as a state machine where events trigger transitions, and each transition results in a new state. This forces a clear separation between what happened (the event) and how the state changes in response (the event handler).

// Events
abstract class CounterEvent {}
class CounterIncrementEvent extends CounterEvent {}
class CounterDecrementEvent extends CounterEvent {}

// BLoC
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncrementEvent>((event, emit) {
      emit(state + 1);
    });
    on<CounterDecrementEvent>((event, emit) {
      emit(state - 1);
    });
  }
}

Notice the difference: with Cubit, you call increment() directly. With BLoC, you call bloc.add(CounterIncrementEvent()). This seemingly small abstraction makes a massive difference as complexity grows.

The Tangled Web: When Cubit Becomes a Trap

Let’s revisit our multi-step checkout example. Imagine our CheckoutCubit with multiple dependent operations.

The Problematic Cubit Approach

// checkout_cubit.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';

// States (trimmed for brevity, assume similar structure to BLoC)
abstract class CheckoutState extends Equatable {
  const CheckoutState();
  @override
  List<Object?> get props => [];
}
class CheckoutInitial extends CheckoutState {}
class CheckoutLoading extends CheckoutState { final String message; const CheckoutLoading(this.message); @override List<Object> get props => [message]; }
class CheckoutShippingReady extends CheckoutState { final String address; const CheckoutShippingReady(this.address); @override List<Object> get props => [address]; }
class CheckoutPaymentReady extends CheckoutState { final String paymentMethod; const CheckoutPaymentReady(this.paymentMethod); @override List<Object> get props => [paymentMethod]; }
class CheckoutPromoApplied extends CheckoutState { final String code; final double discount; const CheckoutPromoApplied(this.code, this.discount); @override List<Object> get props => [code, discount]; }
class CheckoutError extends CheckoutState { final String message; const CheckoutError(this.message); @override List<Object> get props => [message]; }
class CheckoutSuccess extends CheckoutState {}

class CheckoutCubit extends Cubit<CheckoutState> {
  CheckoutCubit() : super(CheckoutInitial());
  String? _currentAddress;
  String? _currentPaymentMethod;
  Future<void> loadInitialData() async {
    emit(const CheckoutLoading('Loading initial data...'));
    await Future.delayed(const Duration(seconds: 1));
    _currentAddress = '123 Main St';
    _currentPaymentMethod = 'Credit Card';
    emit(CheckoutShippingReady(_currentAddress!));
    emit(CheckoutPaymentReady(_currentPaymentMethod!));
  }
  Future<void> updateShippingAddress(String newAddress) async {
    emit(const CheckoutLoading('Validating address...'));
    await Future.delayed(const Duration(milliseconds: 700));
    if (newAddress.length < 5) {
      emit(const CheckoutError('Invalid address.'));
      return;
    }
    _currentAddress = newAddress;
    emit(CheckoutShippingReady(_currentAddress!));
    // Problem: What if a promo depends on address?
    // Calling applyPromoCode() here leads to nested emits and unclear flow.
  }
  Future<void> selectPaymentMethod(String method) async {
    emit(const CheckoutLoading('Updating payment method...'));
    await Future.delayed(const Duration(milliseconds: 500));
    _currentPaymentMethod = method;
    emit(CheckoutPaymentReady(_currentPaymentMethod!));
  }
  Future<void> applyPromoCode(String code) async {
    emit(const CheckoutLoading('Applying promo code...'));
    await Future.delayed(const Duration(seconds: 1));
    if (code == 'DISCOUNT10') {
      emit(const CheckoutPromoApplied('DISCOUNT10', 10.0));
    } else {
      emit(const CheckoutError('Invalid promo code.'));
    }
  }
  Future<void> submitOrder() async {
    if (_currentAddress == null || _currentPaymentMethod == null) {
      emit(const CheckoutError('Please complete shipping and payment details.'));
      return;
    }
    emit(const CheckoutLoading('Submitting order...'));
    await Future.delayed(const Duration(seconds: 2));
    emit(CheckoutSuccess());
  }
}

Look at updateShippingAddress. If applying a promo code depends on the shipping region, we'd have to call applyPromoCode again. Now, one method calls another, both calling emit. If applyPromoCode emits an error, does updateShippingAddress also emit an error, or does it just complete successfully after its own emit? The sequence of states becomes non-obvious. Testing specific scenarios, like "change address then apply valid promo then change payment method", requires careful orchestration of method calls and a deep understanding of internal state.

The biggest pain point was traceability. When a CheckoutLoading state appeared, it was a guessing game to figure out which action triggered it. Was it an address update, a payment method change, or a promo code application? This ambiguity makes debugging a chore and understanding the flow of a complex feature difficult for new team members.

Embracing Structure: The BLoC Approach

With BLoC, we explicitly define every user interaction or system event as an Event. This immediately forces us to think about the intent behind every state change.

The Robust BLoC Solution

// checkout_event.dart
import 'package:equatable/equatable.dart';

abstract class CheckoutEvent extends Equatable {
  const CheckoutEvent();
  @override
  List<Object?> get props => [];
}
class CheckoutInitialDataLoaded extends CheckoutEvent {}
class CheckoutShippingAddressUpdated extends CheckoutEvent {
  final String address;
  const CheckoutShippingAddressUpdated(this.address);
  @override
  List<Object> get props => [address];
}
class CheckoutPaymentMethodSelected extends CheckoutEvent {
  final String method;
  const CheckoutPaymentMethodSelected(this.method);
  @override
  List<Object> get props => [method];
}
class CheckoutPromoCodeApplied extends CheckoutEvent {
  final String code;
  const CheckoutPromoCodeApplied(this.code);
  @override
  List<Object> get props => [code];
}
class CheckoutOrderSubmitted extends CheckoutEvent {}
// checkout_state.dart (same as before for brevity)
// ... (CheckoutState, CheckoutInitial, CheckoutLoading, etc. classes) ...
// checkout_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
// import 'checkout_event.dart'; // Uncomment in real project
// import 'checkout_state.dart'; // Uncomment in real project
class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
  CheckoutBloc() : super(CheckoutInitial()) {
    on<CheckoutInitialDataLoaded>(_onInitialDataLoaded);
    on<CheckoutShippingAddressUpdated>(_onShippingAddressUpdated);
    on<CheckoutPaymentMethodSelected>(_onPaymentMethodSelected);
    on<CheckoutPromoCodeApplied>(_onPromoCodeApplied);
    on<CheckoutOrderSubmitted>(_onOrderSubmitted);
  }
  String? _currentAddress;
  String? _currentPaymentMethod;
  String? _currentPromoCode;
  double _currentDiscount = 0.0;
  Future<void> _onInitialDataLoaded(
    CheckoutInitialDataLoaded event,
    Emitter<CheckoutState> emit,
  ) async {
    emit(const CheckoutLoading('Loading initial data...'));
    await Future.delayed(const Duration(seconds: 1));
    _currentAddress = '123 Main St';
    _currentPaymentMethod = 'Credit Card';
    emit(CheckoutShippingReady(_currentAddress!));
    emit(CheckoutPaymentReady(_currentPaymentMethod!));
  }
  Future<void> _onShippingAddressUpdated(
    CheckoutShippingAddressUpdated event,
    Emitter<CheckoutState> emit,
  ) async {
    emit(const CheckoutLoading('Validating address...'));
    await Future.delayed(const Duration(milliseconds: 700));
    if (event.address.length < 5) {
      emit(const CheckoutError('Invalid address.'));
      return;
    }
    _currentAddress = event.address;
    emit(CheckoutShippingReady(_currentAddress!));
    // If a promo code was active, we now need to re-evaluate it.
    // We do this by adding another event, not by directly calling logic.
    if (_currentPromoCode != null) {
      add(CheckoutPromoCodeApplied(_currentPromoCode!));
    }
  }
  Future<void> _onPaymentMethodSelected(
    CheckoutPaymentMethodSelected event,
    Emitter<CheckoutState> emit,
  ) async {
    emit(const CheckoutLoading('Updating payment method...'));
    await Future.delayed(const Duration(milliseconds: 500));
    _currentPaymentMethod = event.method;
    emit(CheckoutPaymentReady(_currentPaymentMethod!));
  }
  Future<void> _onPromoCodeApplied(
    CheckoutPromoCodeApplied event,
    Emitter<CheckoutState> emit,
  ) async {
    emit(const CheckoutLoading('Applying promo code...'));
    await Future.delayed(const Duration(seconds: 1));
    if (event.code == 'DISCOUNT10') {
      _currentPromoCode = event.code;
      _currentDiscount = 10.0;
      emit(CheckoutPromoApplied(event.code, _currentDiscount));
    } else {
      emit(const CheckoutError('Invalid promo code.'));
      _currentPromoCode = null;
      _currentDiscount = 0.0;
    }
  }
  Future<void> _onOrderSubmitted(
    CheckoutOrderSubmitted event,
    Emitter<CheckoutState> emit,
  ) async {
    if (_currentAddress == null || _currentPaymentMethod == null) {
      emit(const CheckoutError('Please complete shipping and payment details.'));
      return;
    }
    emit(const CheckoutLoading('Submitting order...'));
    await Future.delayed(const Duration(seconds: 2));
    emit(CheckoutSuccess());
  }
}

Now, when _onShippingAddressUpdated needs to re-evaluate the promo code, it doesn't call applyPromoCode() directly. Instead, it adds a new CheckoutPromoCodeApplied event to the BLoC: add(CheckoutPromoCodeApplied(_currentPromoCode!)). This is the critical difference.

This approach centralizes all logic for a given event within its dedicated handler. When CheckoutLoading appears, I immediately know it's a response to an event, and I can check the BlocObserver output to see which event triggered it. This explicit event-to-state mapping dramatically improves debuggability, testability, and maintainability. Each event handler can be tested in isolation, and the sequence of events can be clearly understood by simply looking at the add calls. The boilerplate of separate event classes and dedicated handlers pays dividends in large, complex features.

The Gotcha: When listenWhen and buildWhen Are Too Smart

One particular gotcha that stumped me involved BlocListener and BlocConsumer. These widgets offer optional listenWhen and buildWhen parameters, powerful for performance optimization, preventing unnecessary rebuilds or reactions. However, they can also hide crucial state changes if you're not careful.

Consider our CheckoutBloc. We might have a BlocListener to show snackbars for errors or navigation on success:

BlocListener<CheckoutBloc, CheckoutState>(
  listenWhen: (previous, current) => current is CheckoutError || current is CheckoutSuccess,
  listener: (context, state) {
    if (state is CheckoutError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    } else if (state is CheckoutSuccess) {
      Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => OrderConfirmationScreen()));
    }
  },
  child: // ... your checkout UI ...
)

This looks fine. It listens only when there’s an error or success. But what if our CheckoutBloc, during a complex flow, emitted CheckoutLoading, then CheckoutError (e.g., promo failed), then CheckoutLoading again (e.g., re-evaluating something), and then finally CheckoutSuccess? If your listenWhen is too restrictive and you're expecting certain intermediate states for debugging or logging within the listener, you might miss them. The listener callback only fires if listenWhen returns true for the new state. If a state like CheckoutShippingReady is emitted but listenWhen ignores it, your listener won't even see it, even if you planned to use it for a side effect.

I recall an incident where a user reported that a particular validation error wasn’t showing up. After hours of debugging the BLoC’s internal logic, it turned out the BLoC was indeed emitting CheckoutError correctly. The problem was that the listenWhen for that specific error type was slightly off, or an intermediate "loading" state was causing the listener to miss the subsequent error because it wasn't designed to react to that sequence. The fix was usually to simplify listenWhen or to make sure the state classes themselves were distinct enough to be properly filtered, but it taught me to be extremely cautious with these powerful filtering mechanisms, especially in listeners handling critical feedback or navigation.

Closing Thoughts

After wrestling with multiple complex features and debugging sessions across large Flutter applications, my perspective on BLoC and Cubit has solidified. Cubit remains my go-to for genuinely simple features: a single counter, a data fetch that displays directly, a simple form with minimal interdependent validation. Its minimalist API keeps the code clean and easy to grasp.

However, when a feature starts demanding more: multiple intertwined async operations, state heavily dependent on previous states, complex validation rules reacting to different inputs, or scenarios where the “why” behind a state change is crucial for debugging and maintainability, that’s when I reach for BLoC. The initial boilerplate of defining events and event handlers is an investment. It forces a more structured, explicit design upfront. But this upfront cost is repaid manifold in reduced debugging time, improved testability, and a codebase that communicates its intent far more clearly, even months down the line when someone else (or future me) has to pick it up.


메타데이터
post_id
fb0b82205da9
slug
navigating-complexity-why-blocs-verbosity-saved-my-sanity-on-large-flutter-apps-fb0b82205da9
url
https://medium.com/@mailharshkhatri/navigating-complexity-why-blocs-verbosity-saved-my-sanity-on-large-flutter-apps-fb0b82205da9
canonical_url
https://medium.com/@mailharshkhatri/navigating-complexity-why-blocs-verbosity-saved-my-sanity-on-large-flutter-apps-fb0b82205da9
author_url
https://medium.com/@mailharshkhatri
status
ok
fetched_at
2026-06-23 03:48:11