← Back to blog
Flutter State Management · Part 5 of 7
September 17, 202610 min read

Bloc & Cubit: Event-Driven State Management, Explained Simply

FlutterDartState Management

Bloc & Cubit: Event-Driven State

This is Part 5 of the Flutter State Management series. So far the tools have shared one philosophy: a model holds state, you mutate it, listeners rebuild. Bloc comes from a different school of thought — one borrowed from the world of Redux and reactive architecture — built around explicit, traceable state transitions and immutable states.

If Provider and Riverpod feel like "grab the value and go," Bloc feels like "every change is a documented transaction." That structure is either exactly what your big team needs or more ceremony than your small app wants — and this part gives you the judgment to tell which. We'll start with its friendly little sibling, Cubit, then graduate to full Bloc.

Version: flutter_bloc 9.x / bloc 9.x, Flutter 3.38 / Dart 3.12. Builds on the whole series so far.


The unifying idea: unidirectional data flow

Every Bloc-family solution enforces one rule:

Data flows in one direction: UI → (trigger) → business logic → new immutable state → UI. The UI never mutates state directly; it asks for a change, and receives a brand-new state object back.

Analogy — the restaurant kitchen. You (the UI) don't walk into the kitchen and stir the pot. You submit an order ticket, the kitchen (business logic) cooks, and a finished dish (new state) comes out the pass. You only ever consume the dish; you never touch the cooking. This separation is the entire point — the kitchen's logic is isolated, testable, and every dish is traceable to a ticket.

Cubit and Bloc differ only in how the change is triggered: Cubit uses method calls; Bloc uses events (the order tickets).


Cubit: the simple one (methods → states)

A Cubit holds an immutable state and exposes methods that emit new states. That's it.

import 'package:flutter_bloc/flutter_bloc.dart';

// State is just an int here; for richer state use a class (see below).
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0); // initial state

  void increment() => emit(state + 1); // emit a NEW state
  void decrement() => emit(state - 1);
}

Key points:

  • super(0) sets the initial state.
  • state always holds the current state (read-only).
  • emit(newState) publishes a new state; listeners rebuild. You never mutate state in place — you emit a replacement.

Cubit vs ChangeNotifier: very similar! Both hold state and notify. The differences: Cubit's state is immutable and replaced via emit (not mutated then notifyListeners()), and Cubit plugs into the Bloc tooling (BlocBuilder, observers, etc.). If you liked Provider's ChangeNotifier, Cubit will feel familiar but stricter.


Wiring a Cubit into the UI

Three pieces, all mirroring patterns you've seen:

// 1. Provide it (BlocProvider IS an InheritedWidget under the hood — Part 2):
BlocProvider(
  create: (_) => CounterCubit(),
  child: const CounterView(),
)

// 2. Rebuild on state with BlocBuilder:
BlocBuilder<CounterCubit, int>(
  builder: (context, count) => Text('$count'),
)

// 3. Trigger changes by calling methods via context.read:
onPressed: () => context.read<CounterCubit>().increment(),

Notice the symmetry with Part 3: BlocProviderChangeNotifierProvider, BlocBuilderConsumer, context.read<CounterCubit>() is the same read (call a method, don't subscribe). The lessons compound.


Bloc: the structured one (events → states)

A Bloc replaces methods with events. Instead of calling increment(), the UI adds an Increment event, and the Bloc maps each event type to a state transition via on<Event>.

// 1. Define events (the "order tickets"):
sealed class CounterEvent {}
class Increment extends CounterEvent {}
class Decrement extends CounterEvent {}

// 2. The Bloc maps events → states:
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
    on<Decrement>((event, emit) => emit(state - 1));
  }
}
// 3. UI dispatches events instead of calling methods:
onPressed: () => context.read<CounterBloc>().add(Increment()),

Everything else (BlocProvider, BlocBuilder) is identical to Cubit. The only difference is the trigger: add(event) instead of a method call, with on<Event> handlers in between.

Why add the event ceremony at all? Because every state change now flows through a named, loggable event. You get a complete, ordered audit trail of what the user didIncrement, Increment, Decrement — separate from how state changed. For complex flows (a checkout, a multi-step form, anything you'd want to replay or debug), that traceability is gold. For a simple toggle, it's overkill — use a Cubit.


Real state needs real state classes

int states are toy examples. Production states are usually immutable classes, often a sealed hierarchy (Dart 3 — see Flutter Fundamentals for widget basics) so the UI must handle every case:

sealed class WeatherState {}
class WeatherInitial extends WeatherState {}
class WeatherLoading extends WeatherState {}
class WeatherLoaded extends WeatherState {
  WeatherLoaded(this.temp);
  final double temp;
}
class WeatherError extends WeatherState {
  WeatherError(this.message);
  final String message;
}

class WeatherCubit extends Cubit<WeatherState> {
  WeatherCubit(this._api) : super(WeatherInitial());
  final WeatherApi _api;

  Future<void> fetch(String city) async {
    emit(WeatherLoading());
    try {
      final temp = await _api.getTemp(city);
      emit(WeatherLoaded(temp)); // a brand-new state object
    } catch (e) {
      emit(WeatherError(e.toString()));
    }
  }
}

The UI becomes an exhaustive switch over states — no forgotten loading or error case:

BlocBuilder<WeatherCubit, WeatherState>(
  builder: (context, state) => switch (state) {
    WeatherInitial() => const Text('Search a city'),
    WeatherLoading() => const CircularProgressIndicator(),
    WeatherLoaded(:final temp) => Text('$temp°'),
    WeatherError(:final message) => Text('Error: $message'),
  },
)

This is the same problem Riverpod's AsyncValue solves with a built-in type. Bloc has you model the states explicitly with a sealed class — more verbose, but maximally clear and totally under your control.


The toolkit: Builder, Listener, Consumer

Bloc separates rebuilding UI from firing side-effects — a genuinely nice distinction:

| Widget | Purpose | Analogous to | | --- | --- | --- | | BlocBuilder | Rebuild UI from state | Provider's Consumer | | BlocListener | Run side-effects (snackbar, navigation, dialog) — does not rebuild | Riverpod's ref.listen | | BlocConsumer | Both, in one widget | — |

BlocListener<WeatherCubit, WeatherState>(
  listener: (context, state) {
    if (state is WeatherError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }
  },
  child: const WeatherView(),
)

And like Provider's select, you can gate rebuilds/effects with buildWhen / listenWhen:

BlocBuilder<CounterBloc, int>(
  buildWhen: (prev, curr) => curr % 2 == 0, // rebuild only on even counts
  builder: (context, count) => Text('$count'),
)

Why separate builder from listener? Side-effects (showing a snackbar, navigating) should fire once per transition, not on every rebuild. BlocBuilder can run many times; BlocListener runs exactly once per state change. Mixing them up (e.g. navigating inside a builder) causes duplicate navigations — a classic bug this split prevents.


Cubit or Bloc? A quick rule

| Use Cubit when… | Use Bloc when… | | --- | --- | | Simple state, few transitions | Complex flows, many triggers | | You just want methods | You want an event audit trail | | Toggles, form fields, counters | Checkout, auth flows, anything replayable | | Less boilerplate matters | Traceability/debuggability matters |

Start with Cubit. It's less ceremony and you can graduate a feature to a Bloc later if its event history becomes valuable. Many production apps mix both — Cubits for simple corners, Blocs for the gnarly flows.


Where Bloc sits versus Provider/Riverpod

  • Bloc's strength: rigid, predictable structure — immutable states, explicit transitions, great testability, and (with full Bloc) a complete event log. Teams love it for large, long-lived apps where consistency across many developers matters.
  • Bloc's cost: the most boilerplate of any option here — events, states, handlers, and the wiring. For small apps it can feel heavy.
  • Versus Riverpod: Riverpod is more concise and compile-safe with less ceremony; Bloc is more prescriptive (one obvious way to do things). Both are excellent; the choice is often team culture.

We turn all of this into a concrete decision framework in Part 6.


Practice Challenges

Challenge 1 — Emit, don't mutate. Why is this Cubit wrong?

class TodoCubit extends Cubit<List<String>> {
  TodoCubit() : super([]);
  void add(String t) {
    state.add(t);   // ❌
    emit(state);
  }
}
Show solution

It mutates the existing state list in place, then emits the same instance. Bloc compares states by identity/equality and may skip the rebuild, and mutating shared state breaks the immutability contract. Emit a new list:

void add(String t) => emit([...state, t]);

Challenge 2 — Cubit to Bloc. Convert this Cubit method to a Bloc event + handler.

void increment() => emit(state + 1);
Show solution
class Increment extends CounterEvent {}

// in the Bloc constructor:
on<Increment>((event, emit) => emit(state + 1));

// UI: context.read<CounterBloc>().add(Increment());

Challenge 3 — Builder vs Listener. You need to show a snackbar when an error state arrives. Should it go in BlocBuilder or BlocListener? Why?

Show solution

BlocListener. Snackbars are side-effects that must fire once per transition. BlocBuilder can rebuild multiple times, which would show duplicate snackbars; BlocListener runs exactly once per state change.

Challenge 4 — Exhaustive UI. Why model async state as a sealed class (Loading/Loaded/Error) instead of a class with nullable fields?

Show solution

A sealed hierarchy forces the UI's switch to handle every state (the compiler enforces exhaustiveness), so you can't forget the loading or error case. Nullable fields (data?, error?, isLoading) can drift into impossible/contradictory combinations and silently miss a case.

Challenge 5 — Pick the tool. A multi-step checkout needs an audit of each user action for debugging. Cubit or Bloc, and why?

Show solution

Bloc. Each step becomes a named event (AddressSubmitted, PaymentSelected, OrderPlaced), giving an ordered, loggable trail of exactly what happened — invaluable for debugging complex flows. A Cubit's plain method calls don't produce that event history.


Questions to test yourself

Q1 (basic). What is unidirectional data flow in Bloc?

Show answer

State flows one way: UI triggers a change (method or event) → business logic produces a new immutable state → UI rebuilds from it. The UI never mutates state directly.

Q2 (basic). What's the core difference between Cubit and Bloc?

Show answer

Cubit triggers state changes with method calls (emit inside methods). Bloc triggers them with events (add(event)) mapped to states via on<Event> handlers, giving a traceable event log. Otherwise they're used the same way.

Q3 (intermediate). How do emit and immutability relate, and why not mutate state in place?

Show answer

emit publishes a new state object to replace the current one. States are immutable, so you build a fresh value rather than mutating the old one. Mutating in place breaks equality-based change detection (the rebuild may be skipped) and the one-way-flow contract.

Q4 (intermediate). Why does Bloc separate BlocBuilder from BlocListener?

Show answer

To split rebuilding UI (which can happen many times) from side-effects like snackbars/navigation (which must run once per transition). BlocBuilder rebuilds; BlocListener fires effects exactly once per state change, preventing duplicate-effect bugs.

Q5 (advanced). What does the event layer in full Bloc buy you over Cubit, and what's the trade-off?

Show answer

Every state change flows through a named, ordered, loggable event, giving a full audit trail of user actions — great for complex flows, debugging, and replay. The trade-off is more boilerplate (event classes + handlers), which is overkill for simple state where a Cubit is cleaner.

Q6 (advanced). How does modeling state with a sealed class compare to Riverpod's AsyncValue?

Show answer

Both make async/multi-state handling exhaustive and explicit. Riverpod provides AsyncValue as a built-in type (data/loading/error) you pattern-match; Bloc has you define the sealed states yourself. Bloc is more verbose but fully customizable; AsyncValue is more concise out of the box. See AsyncValue.


Wrapping up

  • Bloc-family enforces unidirectional data flow: UI triggers → logic → new immutable state → UI.
  • Cubit = method-driven (emit in methods) — simple, like a stricter ChangeNotifier.
  • Bloc = event-driven (add(event)on<Event>) — more boilerplate, but a traceable event log.
  • Model real state as immutable sealed classes so the UI handles every case (Bloc's take on AsyncValue).
  • Toolkit: BlocBuilder (rebuild), BlocListener (side-effects, once per transition), BlocConsumer (both), gated by buildWhen/listenWhen.
  • Start with Cubit; reach for Bloc when event traceability earns its ceremony.

In Part 6, the last content part, we put everything side by side: setState vs Provider vs Riverpod vs Bloc — a concrete decision framework for choosing the right tool for each job.