Flutter State Management: Bloc vs Riverpod
A working comparison of the two serious options - the mental models they impose, the same feature built in both, and how to choose without relitigating it every sprint.
Ask which state management package to use in Flutter and you will get an answer within seconds, delivered with total confidence, and contradicted just as confidently by the next person. The debate has been running long enough that both sides have stopped explaining themselves.
That is a shame, because Bloc and Riverpod are not competing implementations of the same idea. They start from different premises about what the hard part of state actually is. Bloc says the hard part is understanding how state changed. Riverpod says the hard part is knowing what state depends on what. Once you see that, most of the argument resolves into a question about your app rather than about the packages.
This post covers the mental model each imposes, the same feature built in both, where each one hurts, and how I actually choose.
Two different bets about what is hard
Both packages solve the same surface problem - get data into widgets, rebuild
when it changes, keep business logic out of build. They disagree about what
goes wrong at scale.
Bloc bets on traceability
Bloc models state as a stream of transitions driven by explicit events. You do not set state; you dispatch an event, and a handler decides what the new state is.
sealed class CartEvent {}
class ItemAdded extends CartEvent {
ItemAdded(this.item);
final Item item;
}
class CartCleared extends CartEvent {}
sealed class CartState {}
class CartLoading extends CartState {}
class CartReady extends CartState {
CartReady(this.items);
final List<Item> items;
}
class CartError extends CartState {
CartError(this.message);
final String message;
}
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc(this._addItem) : super(CartLoading()) {
on<ItemAdded>(_onItemAdded);
on<CartCleared>((_, emit) => emit(CartReady(const [])));
}
final AddItem _addItem;
Future<void> _onItemAdded(ItemAdded event, Emitter<CartState> emit) async {
final result = await _addItem(event.item);
switch (result) {
case Success(value: final items):
emit(CartReady(items));
case FailureResult(failure: final f):
emit(CartError(f.userMessage));
}
}
}That verbosity buys something specific. Every change to the cart has a named
cause. BlocObserver can log every event and every transition globally, so a
bug report becomes a replayable sequence rather than a guess. When someone asks
"how did the cart end up empty?", the answer is a line in a log, not an
archaeology session across widgets.
The cost is equally specific: three declarations - event, state, handler - for something a single assignment would express.
Riverpod bets on composition
Riverpod models state as a graph of providers that declare their dependencies. You do not dispatch; you read, and Riverpod tracks who read what.
@riverpod
Future<List<Item>> cart(CartRef ref) async {
final repo = ref.watch(cartRepositoryProvider);
return repo.items();
}
@riverpod
Money cartTotal(CartTotalRef ref) {
final items = ref.watch(cartProvider).valueOrNull ?? const [];
return items.fold(Money.zero, (sum, i) => sum + i.price);
}cartTotal recomputes when cart changes, automatically, because it watched
it. Nobody wires that up and nobody can forget to. Add a fifth derived value and
the graph absorbs it without a single line of plumbing.
The cost is that causation becomes implicit. When something rebuilt unexpectedly, the answer is somewhere in the dependency graph rather than in a list of dispatched events.
The Bloc vocabulary
Both packages bury newcomers in nouns. Here is the whole Bloc surface, grouped by what it is for.
The state holders
| Term | What it is |
|---|---|
Bloc | Event in, state out. Every change has a named cause. |
Cubit | Bloc without events - you call a method and it emits. Less ceremony, less traceability. |
Emitter | The emit callback passed to a handler; pushes a new state. |
on<Event> | Registers a handler for one event type inside a bloc. |
BlocBase | Shared parent of Bloc and Cubit; where state and close() live. |
Getting them into the tree
| Term | What it is |
|---|---|
BlocProvider | Creates and disposes a bloc, exposing it to descendants. |
MultiBlocProvider | Nests several providers without a pyramid of indentation. |
RepositoryProvider | The same mechanism for plain dependencies - repositories, clients. |
BlocProvider.value | Passes an existing bloc down without owning its lifecycle. |
context.read<T>() | One-off lookup, no subscription. Safe inside callbacks. |
context.watch<T>() | Subscribes the widget to the bloc and rebuilds on change. |
Rendering from them
| Term | What it is |
|---|---|
BlocBuilder | Rebuilds on new state. The workhorse. |
BlocListener | Reacts without rebuilding - snackbars, navigation, dialogs. |
BlocConsumer | Both at once, when you need to build and react. |
BlocSelector | Rebuilds only when a selected slice of state changes. |
buildWhen / listenWhen | Predicates to suppress unnecessary rebuilds or reactions. |
StreamBuilder | Flutter's own primitive. Bloc is essentially a disciplined layer over it - worth knowing, rarely needed directly once you use Bloc. |
Tooling around it
| Term | What it is |
|---|---|
BlocObserver | Global hook for every event, transition, and error. The debugging superpower. |
Transition | The record of { currentState, event, nextState }. |
Change | The same for a Cubit, minus the event. |
| Event transformers | sequential, concurrent, droppable, restartable from bloc_concurrency - declarative control over overlapping events. |
hydrated_bloc | Persists and restores state across app restarts automatically. |
bloc_test | blocTest with build / act / expect for concise behavioural tests. |
flutter_bloc | The Flutter bindings; bloc itself is pure Dart. |
The one important choice in that list is Bloc versus Cubit. Cubit drops events, so you lose the named cause and the replayable log - which is the entire argument for choosing Bloc in the first place. My rule: Cubit for simple UI state with one or two transitions, Bloc for anything a support ticket might ask about later. Mixing both in one app is fine and normal.
The Riverpod vocabulary
Riverpod's surface is wider, because the provider is the unit of composition.
Provider kinds
| Term | What it is |
|---|---|
Provider | A computed or constant value. Repositories, config, derived data. |
StateProvider | A single mutable value. Filters, toggles, selected ids. |
FutureProvider | Async value exposed as AsyncValue. Loading and error states for free. |
StreamProvider | The same for a stream. |
NotifierProvider | A class with methods that mutate state - the modern replacement for StateNotifierProvider. |
AsyncNotifierProvider | Notifier whose state is an AsyncValue; the usual home for a screen's logic. |
StateNotifierProvider | Legacy; still everywhere in older code. |
ChangeNotifierProvider | Escape hatch for Flutter classes like TextEditingController. Discouraged for app state. |
Reading them
| Term | What it is |
|---|---|
ref.watch | Subscribe and rebuild/recompute on change. The default. |
ref.read | One-off read, no subscription. For callbacks only - never in build. |
ref.listen | Side effects on change: snackbars, navigation. The BlocListener analogue. |
ref.invalidate | Throws the value away so it recomputes on next read. |
ref.refresh | Invalidate and immediately read the new value. |
ref.onDispose | Cleanup when the provider is destroyed. |
select | Subscribe to one field, so unrelated changes do not rebuild. |
Lifecycle and shape
| Term | What it is |
|---|---|
family | Parameterised providers - one per id, e.g. userProvider(userId). |
autoDispose | Destroys state when nothing is listening. Default under code generation. |
keepAlive | Opts a provider out of auto-disposal. |
AsyncValue | The data / loading / error union, exhaustively switchable. |
AsyncValue.guard | Wraps an async call so errors land in the union instead of throwing. |
Wiring and tooling
| Term | What it is |
|---|---|
ProviderScope | Root widget holding all provider state. Required once, at the top. |
ConsumerWidget | Stateless widget with a WidgetRef in build. |
ConsumerStatefulWidget | The stateful equivalent, with ref on the state class. |
Consumer | Scopes a rebuild to a subtree without converting the whole widget. |
ProviderContainer | Provider state outside the widget tree - how you test without pumping. |
overrides | Swap a provider's implementation, for tests, mocks, or per-flavour config. |
ProviderObserver | Global hook for provider lifecycle. Riverpod's BlocObserver analogue, less chronological. |
@riverpod | Code generation that writes the provider declarations and improves inference. |
riverpod_lint | Catches the classic mistakes, notably ref.read inside build. |
The important distinction in that list is ref.watch versus ref.read.
watch subscribes; read does not. Using read in build produces a widget
that renders once and never updates again - the single most common Riverpod bug,
and the reason riverpod_lint exists.
The translation table
If you already know one package, this is the fastest way into the other. Read across the row.
Holding state
| Bloc | Riverpod | Notes |
|---|---|---|
Bloc | Notifier / AsyncNotifier | Riverpod has no event concept - you call methods directly. |
Cubit | Notifier | The closest one-to-one match in the whole table. |
StateNotifier (via flutter_bloc peers) | NotifierProvider | StateNotifier is legacy on both sides now. |
| - | StateProvider | No Bloc equivalent; you would write a one-field Cubit. |
| - | FutureProvider / StreamProvider | In Bloc you emit Loading / Loaded / Error states by hand. |
emit(newState) | state = newState | Same idea, different ceremony. |
on<Event>(handler) | A method on the notifier | Bloc routes by event type; Riverpod calls the method. |
Wiring it into the tree
| Bloc | Riverpod | Notes |
|---|---|---|
BlocProvider | Declaring a provider + ProviderScope | Riverpod's providers are global; scoping is the exception, not the rule. |
MultiBlocProvider | (nothing needed) | No nesting problem to solve - providers are declared, not mounted. |
RepositoryProvider | Provider | Same job: expose a dependency. |
BlocProvider.value | overrides on a ProviderScope | How you inject an existing instance. |
ProviderScope at root | ProviderScope at root | Bloc needs no root; Riverpod requires exactly one. |
Reading it in widgets
| Bloc | Riverpod | Notes |
|---|---|---|
BlocBuilder | ref.watch(p) | The everyday read. |
BlocListener | ref.listen(p, cb) | Side effects without rebuilding. |
BlocConsumer | ref.watch + ref.listen | Riverpod does not bundle them. |
BlocSelector | ref.watch(p.select(...)) | Narrow the subscription. |
buildWhen | select | Different mechanism, same outcome. |
context.read<T>() | ref.read(p) | One-off, callbacks only. |
context.watch<T>() | ref.watch(p) | Subscribing read. |
ConsumerWidget | - | Bloc uses ordinary widgets; Riverpod needs ref from somewhere. |
Derived and parameterised state
| Bloc | Riverpod | Notes |
|---|---|---|
| Subscribe to another bloc in the constructor | ref.watch(otherProvider) | This is Riverpod's headline advantage - three lines vs a subscription. |
| One bloc instance per id, managed manually | family | Riverpod handles the keying and disposal. |
close() in BlocProvider | autoDispose | Riverpod disposes automatically by default under code generation. |
Async, errors, and lifecycle
| Bloc | Riverpod | Notes |
|---|---|---|
Loading / Loaded / Error states | AsyncValue | Bloc: you declare them. Riverpod: you get them. |
try/catch in the handler | AsyncValue.guard | Both funnel errors into state. |
emit.forEach / emit.onEach | StreamProvider | Bridging a stream into state. |
close() | ref.onDispose | Cleanup hook. |
Event transformers (droppable, restartable) | - | No Riverpod equivalent. Debounce and concurrency are manual. |
Tooling and testing
| Bloc | Riverpod | Notes |
|---|---|---|
BlocObserver | ProviderObserver | Bloc's is chronological and event-named; Riverpod's is lifecycle-based. |
Transition / Change | Provider didUpdateProvider | Bloc gives you the cause; Riverpod gives you before/after. |
bloc_test (blocTest) | ProviderContainer + overrides | Riverpod needs no dedicated test package. |
hydrated_bloc | Manual, or shared_preferences in a notifier | Bloc wins here - persistence is one mixin. |
| - | riverpod_lint | Catches ref.read in build and similar mistakes. |
Read the dashes carefully - they mark the two places where a genuine capability
gap exists rather than a naming difference. Riverpod has no event transformers,
and Bloc has no equivalent of family or automatic dependency tracking.
The same feature, both ways
Abstract comparisons are unfalsifiable, so here is one screen - a searchable list with loading and error states - in both.
Riverpod version
@riverpod
class SearchQuery extends _$SearchQuery {
@override
String build() => '';
void update(String value) => state = value;
}
@riverpod
Future<List<Product>> searchResults(SearchResultsRef ref) async {
final query = ref.watch(searchQueryProvider);
if (query.isEmpty) return const [];
// Debounce, and cancel automatically if the query changes again.
await Future<void>.delayed(const Duration(milliseconds: 300));
ref.onDispose(() {});
final repo = ref.watch(productRepositoryProvider);
return repo.search(query);
}
class SearchPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final results = ref.watch(searchResultsProvider);
return Column(
children: [
TextField(
onChanged: (v) => ref.read(searchQueryProvider.notifier).update(v),
),
Expanded(
child: switch (results) {
AsyncData(:final value) => ProductList(value),
AsyncError(:final error) => ErrorView('$error'),
_ => const Center(child: CircularProgressIndicator()),
},
),
],
);
}
}Roughly forty lines. The loading and error states come from AsyncValue rather
than being declared by hand, and stale in-flight requests are discarded by
Riverpod's own invalidation rather than by a cancellation token you maintain.
Bloc version
sealed class SearchEvent {}
class QueryChanged extends SearchEvent {
QueryChanged(this.query);
final String query;
}
sealed class SearchState {}
class SearchIdle extends SearchState {}
class SearchLoading extends SearchState {}
class SearchLoaded extends SearchState {
SearchLoaded(this.products);
final List<Product> products;
}
class SearchFailed extends SearchState {
SearchFailed(this.message);
final String message;
}
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc(this._repo) : super(SearchIdle()) {
on<QueryChanged>(
_onQueryChanged,
transformer: debounceRestartable(const Duration(milliseconds: 300)),
);
}
final ProductRepository _repo;
Future<void> _onQueryChanged(
QueryChanged event,
Emitter<SearchState> emit,
) async {
if (event.query.isEmpty) return emit(SearchIdle());
emit(SearchLoading());
final result = await _repo.search(event.query);
switch (result) {
case Success(value: final products):
emit(SearchLoaded(products));
case FailureResult(failure: final f):
emit(SearchFailed(f.userMessage));
}
}
}Roughly seventy lines for the same behaviour, and every state is spelled out.
The transformer is worth dwelling on: Bloc's event transformers handle
debounce, throttle, and drop-versus-restart concurrency declaratively. That is
really excellent, and it is the piece Riverpod has no direct equivalent for - in Riverpod you either roll the debounce by hand or lean on invalidation
semantics that are less explicit about intent.
Neither snippet is unfair to its package. That difference in length is real, and so is the difference in explicitness.
Where each one hurts
Bloc hurts on derived state. Cart total, filtered lists, a badge count
combining three sources - each combination is either a stream subscription in a
bloc's constructor or a BlocSelector in the widget tree. Riverpod expresses
the same thing as a provider that watches two others, in three lines.
Riverpod hurts on debuggability. A widget rebuilding more than expected
means walking the provider graph. Riverpod's observer helps, but there is no
equivalent of Bloc's flat, chronological event log. Nor is there a name for what
just happened - a state changed, but nothing says why in the way ItemAdded
does.
Bloc hurts on boilerplate, obviously, and the pain scales with the number of small features rather than the complexity of any one.
Riverpod hurts on discipline. Because ref.read is available anywhere, the
same freedom that makes it concise makes it easy to scatter business logic
across widgets. Bloc's ceremony pushes back against that; Riverpod requires the
team to push back on its own.
Choosing without relitigating it every sprint
Both are mature, well maintained, and used in serious production apps. There is no wrong answer here, only a fit - so decide once and write down why.
Reach for Bloc when the app is event-heavy and auditable: payments, healthcare, anything where "what sequence of actions led here" is a question you will be asked by someone who is not a developer. Also when the team is large or turns over, because the ceremony enforces a shape rather than trusting everyone to choose the same one.
Reach for Riverpod when the app is data-heavy and derivation-heavy: dashboards, feeds, anything with many interdependent values computed from a few sources. Also on smaller teams where the boilerplate tax is felt every day and the discipline can be maintained by convention.
A note on the thing that is not actually the difference. Both keep business logic out of widgets, both test well, both work fine with Clean Architecture, and neither will be your performance bottleneck. Arguments that treat one as "more architectural" than the other are usually comparing a well-structured codebase to a badly structured one.
If you already layer your app properly, this choice touches the presentation layer only - which is a good reason not to agonise over it, and a good reason the migration cost is bounded if you get it wrong.
Bloc vs Riverpod in Flutter: which should you use?
The whole comparison, across the six dimensions that actually decide it.
| Bloc | Riverpod | |
|---|---|---|
| Architecture | Event-driven. Events in, states out, one named cause per transition. Enforces a shape you cannot easily deviate from. | Graph-driven. Providers declare dependencies and Riverpod tracks them. Flexible, and relies on you to impose a shape. |
| State management | Explicit sealed state classes; you declare every case including loading and error. Verbose but exhaustive. | AsyncValue gives loading/data/error for free; derived state is a provider that watches another. Concise, less explicit. |
| Dependency injection | Not a DI container - you pair it with get_it or RepositoryProvider. Two systems to learn. | DI is the model. Providers are the container; overrides swap implementations per environment or test. |
| Testing | bloc_test gives a tight build/act/expect harness. Excellent, but a dedicated package. | ProviderContainer plus overrides - no extra package, no widget pump. Marginally simpler. |
| Performance | Rebuilds scoped with BlocBuilder, buildWhen, BlocSelector. Manual but predictable. | Rebuilds scoped by what you watch and select. Finer-grained by default, easier to over-subscribe by accident. |
| When to choose | Auditable, event-heavy domains - payments, health, compliance. Large or high-turnover teams where enforced structure beats flexibility. | Data- and derivation-heavy apps - dashboards, feeds, caches. Smaller teams that feel the boilerplate tax daily. |
Two rows deserve a caveat.
Dependency injection is the most one-sided row here. Riverpod actually
replaces get_it; Bloc really does not try to. If having one system instead
of two matters to you, that is a real point for Riverpod.
Performance is the row that matters least. Both are fast enough that your bottleneck will be layout, image decoding, or an unindexed query long before it is your state management package. Treat that row as a tiebreaker, not a criterion.
Key takeaways
- They optimise for different problems. Bloc for traceability of change, Riverpod for composition of dependencies.
- Bloc's event log is the strongest argument for it. A named cause for every transition is worth real boilerplate in an auditable app.
- Riverpod's derived providers are the strongest argument for it. Dependent state costs three lines instead of a subscription.
- Bloc's event transformers are the best debounce/throttle/concurrency story in Flutter, and have no direct Riverpod equivalent.
- Riverpod asks more of your discipline, because
ref.readworks anywhere and nothing stops logic drifting into widgets. - If your app is layered, this is a presentation-layer decision. Bounded blast radius, so decide once and move on.
FAQ
Can I use both in one app?
Technically yes, and occasionally it is pragmatic during a migration. As a steady state it is a bad idea: two mental models means every new developer learns both, and every piece of state prompts a debate about which to use.
Is setState ever enough?
Frequently. A toggle, an animation controller, a form field's focus - none of that belongs in a global store. Reaching for a package for local ephemeral state adds indirection with nothing in return.
What about Provider, GetX, MobX, or signals?
Provider is effectively superseded by Riverpod, by the same author. GetX
bundles routing, DI, and state behind a lot of implicit magic, which is exactly
what a large codebase does not want. MobX is solid but has a much smaller Flutter
community, which matters when you are hiring or searching for answers. Signals
are promising and worth watching; I would not build a multi-year app on them
today.
Which is easier to test?
Both are good. bloc_test gives you a concise blocTest harness with
build/act/expect. Riverpod's ProviderContainer with overridden
dependencies is arguably simpler still, since it needs no special test package.
Neither should be a factor in the decision.
Does Riverpod's code generation matter?
It removes most of the provider-declaration boilerplate and improves type
inference, and it is the recommended path now. It does add build_runner to
your workflow, which is a real ergonomic cost on a large project - watch mode is
not free.
How painful is migrating later?
If business logic lives in use cases and repositories rather than in blocs or notifiers, you are rewriting the presentation layer only - mechanical and boring, but bounded. If your business rules live inside your state classes, the migration is a rewrite. That is an argument for layering, not for either package.
Conclusion
Bloc and Riverpod are both good enough that the choice will not decide whether your app succeeds. What will decide it is whether your business rules live somewhere testable and whether the next developer can find out why a value changed.
Pick Bloc if you would rather pay in boilerplate and get a chronological account of everything that happened. Pick Riverpod if you would rather pay in implicit causation and get effortless derived state. Then write the reason in the repo, so the next person to raise it can read a decision instead of restarting the argument.
Read more
If you are setting up a new Flutter project, the state management choice matters less than the layering underneath it - start with Building Scalable Flutter Apps with Clean Architecture, then pick whichever of these two fits the way your team thinks.