Riverpod vs Bloc, a year later
Receipts from production. Where each fits, where each fails, and what we wish someone had told us.
Loading essay…
Receipts from production. Where each fits, where each fails, and what we wish someone had told us.
Loading essay…
A year ago we had two real Flutter apps in production, one on Bloc and one on Riverpod, both written by the same team. This is what we learned. Both libraries are good. They optimise for different things and one of those things matters more than the other for most teams.
Skip the marketing. Here is the structural difference:
Bloc class with
event handlers) and the consumer (BlocBuilder / BlocListener).Bloc draws a line between events, state, and rendering. Riverpod draws a graph of values that fan into rendering. Both work. The difference is what they prevent you from doing.
Two situations:
If your screen is a state machine (an OTP flow, a checkout, an
onboarding wizard), Bloc's on<Event> handlers map cleanly to the
states the user can be in. Reading the bloc file is reading the
state machine. New team members can navigate it.
class OtpBloc extends Bloc<OtpEvent, OtpState> {
OtpBloc() : super(const OtpInitial()) {
on<OtpRequested>(_onRequested);
on<OtpResent>(_onResent);
on<OtpEntered>(_onEntered);
on<OtpVerified>(_onVerified);
on<OtpExpired>(_onExpired);
}
}Tests against a Bloc are unusually good: send events, assert state sequences, done. We had > 90 % branch coverage on our Bloc files without trying.
When the events are themselves valuable (analytics, audit, replay), Bloc gives them to you for free. We piped Bloc events into a single analytics sink and got user-flow tracking with zero extra code per screen.
Three situations:
A Riverpod provider can be autoDispose, family-keyed, scoped to a
route, scoped to the app. The lifetime of state in Riverpod is a
first-class concept; in Bloc, you wrap providers around the widget
tree and pray.
final invoiceProvider = FutureProvider.family.autoDispose<Invoice, String>(
(ref, id) async {
final db = ref.watch(databaseProvider);
return db.invoices.byId(id);
},
);The provider is created when the screen needs it, disposed when the screen leaves, family-keyed by id so two open screens with two different ids do not collide. Doing this in Bloc requires manual provider scoping in the widget tree.
When state value B depends on A, and C depends on A and B, and D depends on B, the Riverpod graph reads cleanly:
final filtersProvider = StateProvider<InvoiceFilters>((ref) => InvoiceFilters.empty());
final allInvoicesProvider = Provider((ref) => ref.watch(databaseProvider).invoices.all);
final filteredInvoicesProvider = Provider((ref) {
final filters = ref.watch(filtersProvider);
final all = ref.watch(allInvoicesProvider);
return all.where(filters.matches);
});
final summaryProvider = Provider((ref) {
final filtered = ref.watch(filteredInvoicesProvider);
return InvoiceSummary.from(filtered);
});The same setup in Bloc requires events that flow through a sequence of blocs or one large bloc with many state branches. Either way is more code.
For trivial state that nobody will ever need to test, Riverpod's
StateProvider is one line. Bloc's minimum is a Bloc class with at
least one event and one state. The ceremony adds up over a week.
Bloc's "wrap the consumer with a BlocProvider" pattern starts to
generate widget trees with five layers of providers above each
screen. We had files where the MultiBlocProvider was longer than
the rest of the screen. Code review fatigue is real.
The graph model is great until the screen is fundamentally a state
machine. We had a Riverpod-backed checkout flow where the "current
step" was a StateProvider, and every transition manually flipped
it. Reading that flow back six months later was harder than reading
a bloc would have been. The transitions were scattered across the
file.
The lesson is not "pick one." It is "pick per surface."
| Surface | Library |
|---|---|
| Domain caches (invoices, contacts, products) | Riverpod |
| Settings, user prefs | Riverpod |
| Auth flow, OTP, onboarding | Bloc |
| Checkout, multi-step forms | Bloc |
| Filter panels, search results | Riverpod |
| Connectivity / sync state | Riverpod |
| Screen-local trivial state (counters, toggles) | Riverpod |
The teams that argue about "which one wins" tend to be teams that have committed to one for the whole app. Once you accept that different surfaces have different shapes, the choice gets easier.
Three things, with a year of hindsight:
The next part of this series jumps to a different surface: AI-paired engineering in production, what it actually looks like outside the demo. Or check the series page for the rest of the offline-Flutter arc.