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_bloc9.x /bloc9.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.statealways holds the current state (read-only).emit(newState)publishes a new state; listeners rebuild. You never mutatestatein place — you emit a replacement.
Cubit vs
ChangeNotifier: very similar! Both hold state and notify. The differences: Cubit's state is immutable and replaced viaemit(not mutated thennotifyListeners()), and Cubit plugs into the Bloc tooling (BlocBuilder, observers, etc.). If you liked Provider'sChangeNotifier, 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:
BlocProvider≈ChangeNotifierProvider,BlocBuilder≈Consumer,context.read<CounterCubit>()is the sameread(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 did —
Increment,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
AsyncValuesolves 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.
BlocBuildercan run many times;BlocListenerruns exactly once per state change. Mixing them up (e.g. navigating inside abuilder) 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 (
emitin methods) — simple, like a stricterChangeNotifier. - 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 bybuildWhen/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.