Skip to content
ExponaBLOG
FLUTTER · DAY 031 / 100

Cubit or Bloc: what every Flutter developer should know

Start with Cubit, graduate to Bloc.

Expona Admin6 min read

Cubit or Bloc?

After five years of shipping production platforms, I've watched teams waste weeks debating whether to reach for Cubit or Bloc in their Flutter apps. The truth is simpler than you think: Cubit gives you reactive state management with minimal overhead, while Bloc adds structure for traceability and complex event flows. Let me walk you through when each shines and when you're likely over-engineering.

Understanding Cubit: Functions That Emit States

Cubit is the lightweight option. It's a class that extends Cubit<State> and exposes methods that call emit(newState) to push changes to listeners. No events, no transformation layers—just direct function calls.

Here's a practical example. I built a cart feature for an e-commerce platform where the user taps a button to add an item. With Cubit, this is straightforward:

dart
class CartCubit extends Cubit<CartState> {
  CartCubit() : super(CartState.empty());

  Future<void> addItem(Product product) async {
    try {
      emit(CartState.loading());
      final cart = await repository.addToCart(product);
      emit(CartState.success(cart));
    } catch (e) {
      emit(CartState.error(e.toString()));
    }
  }
}

class CartState {
  final List<Product> items;
  final bool isLoading;
  final String? error;

  CartState({
    required this.items,
    required this.isLoading,
    this.error,
  });

  factory CartState.empty() => CartState(items: [], isLoading: false);
  factory CartState.loading() => CartState(items: [], isLoading: true);
  factory CartState.success(Cart cart) => CartState(items: cart.items, isLoading: false);
  factory CartState.error(String message) => CartState(items: [], isLoading: false, error: message);
}

The widget layer consumes this with BlocBuilder or BlocListener, the same as Bloc. The key difference: when the user taps "add to cart," you call context.read<CartCubit>().addItem(product). The Cubit method runs, handles the async work, and emits the result. That's it.

Cubits work brilliantly for straightforward workflows: authentication, theme switching, simple counters, or single-action operations. They're easier to test, require less boilerplate, and are faster to prototype. I use Cubits in roughly 70% of my feature work because the overhead of Bloc isn't justified.

Understanding Bloc: Events, Transformers, and Traceability

Bloc sits on top of Cubit and adds a layer of indirection. Instead of calling methods directly, the UI sends events to the Bloc. The Bloc listens to events, applies business logic (often with transformers for sophisticated handling), and emits states. This extra step gives you explicit event handling, logging, replay capability, and debugging power.

I reached for Bloc when building a live trading feed for a financial platform. The requirements were brutal: handle rapid-fire price updates, debounce user search inputs, cancel previous requests if the user changes filters, and maintain an audit trail for compliance. Bloc's event-driven architecture became essential.

Here's a simplified version:

dart
// Events
abstract class PriceEvent {}
class FetchPricesEvent extends PriceEvent {
  final String symbol;
  FetchPricesEvent(this.symbol);
}
class CancelFetchEvent extends PriceEvent {}

// State
class PriceState {
  final List<PriceUpdate> prices;
  final bool isLoading;
  final String? error;

  PriceState({required this.prices, required this.isLoading, this.error});
}

// Bloc
class PriceBloc extends Bloc<PriceEvent, PriceState> {
  final PriceRepository repository;

  PriceBloc(this.repository) : super(PriceState(prices: [], isLoading: false)) {
    on<FetchPricesEvent>(
      (event, emit) async {
        emit(PriceState(prices: [], isLoading: true));
        try {
          final prices = await repository.fetchPrices(event.symbol);
          emit(PriceState(prices: prices, isLoading: false));
        } catch (e) {
          emit(PriceState(prices: [], isLoading: false, error: e.toString()));
        }
      },
      transformer: debounceDroppable(Duration(milliseconds: 500)),
    );

    on<CancelFetchEvent>((event, emit) {
      emit(PriceState(prices: [], isLoading: false));
    });
  }
}

The UI now sends events instead of calling methods:

dart
context.read<PriceBloc>().add(FetchPricesEvent('AAPL'));

The transformer parameter (here debounceDroppable) controls how Bloc processes events. This is where Bloc's power emerges: you can throttle, debounce, concatenate, or drop events without scattered conditional logic in your UI.

Bloc also logs every event and state transition by default, which saved me hours debugging race conditions in production. When you enable Bloc observer logging, you get a complete audit trail of user interactions and state changes.

Bloc Helps with Debouncing and Complex Event Handling

This deserves its own focus because it's where Bloc justifies its complexity. In a CRM platform I worked on, users search for accounts by name. A naive implementation would fire an API call on every keystroke—wasteful and slow. With Cubit, you'd add manual debounce logic in the cubit method:

dart
class SearchCubit extends Cubit<SearchState> {
  Timer? _debounce;

  Future<void> search(String query) async {
    _debounce?.cancel();
    _debounce = Timer(Duration(milliseconds: 300), () async {
      emit(SearchState.loading());
      final results = await repository.search(query);
      emit(SearchState.success(results));
    });
  }
}

It works, but the debounce logic lives in the Cubit. Now add cancellation, retry logic, and priority—your Cubit becomes a spaghetti of timers and flags.

With Bloc and transformers, this is declarative:

dart
class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(SearchState.idle()) {
    on<SearchQueryChangedEvent>(
      (event, emit) async {
        emit(SearchState.loading());
        final results = await repository.search(event.query);
        emit(SearchState.success(results));
      },
      transformer: debounceDroppable(Duration(milliseconds: 300)),
    );
  }
}

The debounceDroppable transformer (from bloc_concurrency) handles the hard work. If three search events fire within 300ms, only the last one runs. The previous API calls are cancelled. No manual timer management, no race conditions.

I've also used throttleTime for scroll listeners, concatMap for sequential operations (like form validation chains), and switchMap for cancelling prior work when new work arrives. These transformers are Bloc's killer feature for event-heavy apps.

Both Use the Same Widgets

This is crucial and often misunderstood. Whether you pick Cubit or Bloc, the UI code is nearly identical. Both work with BlocBuilder, BlocListener, and BlocConsumer:

dart
BlocBuilder<CartCubit, CartState>(
  builder: (context, state) {
    if (state.isLoading) return LoadingSpinner();
    if (state.error != null) return ErrorText(state.error!);
    return CartList(state.items);
  },
);

Replace CartCubit with CartBloc and the widget is unchanged. This matters because it means your choice between Cubit and Bloc is an implementation detail. You can start with Cubit and promote to Bloc later if complexity demands it, or vice versa—the UI layer doesn't care.

I often prototype features with Cubit for speed. Once I hit a wall (complex event handling, debouncing, or the need for event logging), I extract the logic into a Bloc. The refactor takes 15 minutes because the widget layer doesn't change.

Common Mistakes

Over-engineering with Bloc from day one. I see junior developers wrap every piece of state in a Bloc "just in case." Don't. Use Cubit until you have a concrete reason for Bloc. A button toggle doesn't need event transformers.

Forgetting that Bloc events still need debouncing in the UI. If you send SearchQueryChangedEvent on every keystroke without a UI-level debounce, the transformer won't save you. Debouncing should happen at both layers: the UI listens to user input with a debounce, then sends a single event to Bloc.

Putting too much logic in event handlers. Bloc event handlers should be thin. Complex business logic belongs in repositories or services. If your on<SomeEvent> handler spans 50 lines, move the logic elsewhere.

Ignoring testing implications. Both Cubit and Bloc are testable, but Bloc's event-driven design makes it easier to test complex sequences. If you're mocking async dependencies heavily, Bloc's explicit event handling often leads to cleaner tests.

Key Takeaways

  • Start with Cubit. It's simpler, faster to write, and sufficient for most features.
  • Graduate to Bloc when you need event transformers, complex debouncing, event cancellation, or audit logging.
  • The same widgets consume Cubit and Bloc, so switching later is low-cost.
  • Transformers (debounceDroppable, throttleTime, concatMap) are Bloc's unique strength—use them to handle event complexity declaratively.
  • Both require discipline: put business logic in repositories, keep event handlers thin, and test state transitions thoroughly.
  • For small apps or features, Cubit is probably all you need. For production platforms with complex user flows, Bloc's structure and traceability pay dividends.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles