NK

Search

Search pages, posts, and components

All posts
Mobile19 min read

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.

flutterdartstate-managementblocriverpod

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

TermWhat it is
BlocEvent in, state out. Every change has a named cause.
CubitBloc without events - you call a method and it emits. Less ceremony, less traceability.
EmitterThe emit callback passed to a handler; pushes a new state.
on<Event>Registers a handler for one event type inside a bloc.
BlocBaseShared parent of Bloc and Cubit; where state and close() live.

Getting them into the tree

TermWhat it is
BlocProviderCreates and disposes a bloc, exposing it to descendants.
MultiBlocProviderNests several providers without a pyramid of indentation.
RepositoryProviderThe same mechanism for plain dependencies - repositories, clients.
BlocProvider.valuePasses 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

TermWhat it is
BlocBuilderRebuilds on new state. The workhorse.
BlocListenerReacts without rebuilding - snackbars, navigation, dialogs.
BlocConsumerBoth at once, when you need to build and react.
BlocSelectorRebuilds only when a selected slice of state changes.
buildWhen / listenWhenPredicates to suppress unnecessary rebuilds or reactions.
StreamBuilderFlutter's own primitive. Bloc is essentially a disciplined layer over it - worth knowing, rarely needed directly once you use Bloc.

Tooling around it

TermWhat it is
BlocObserverGlobal hook for every event, transition, and error. The debugging superpower.
TransitionThe record of { currentState, event, nextState }.
ChangeThe same for a Cubit, minus the event.
Event transformerssequential, concurrent, droppable, restartable from bloc_concurrency - declarative control over overlapping events.
hydrated_blocPersists and restores state across app restarts automatically.
bloc_testblocTest with build / act / expect for concise behavioural tests.
flutter_blocThe 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

TermWhat it is
ProviderA computed or constant value. Repositories, config, derived data.
StateProviderA single mutable value. Filters, toggles, selected ids.
FutureProviderAsync value exposed as AsyncValue. Loading and error states for free.
StreamProviderThe same for a stream.
NotifierProviderA class with methods that mutate state - the modern replacement for StateNotifierProvider.
AsyncNotifierProviderNotifier whose state is an AsyncValue; the usual home for a screen's logic.
StateNotifierProviderLegacy; still everywhere in older code.
ChangeNotifierProviderEscape hatch for Flutter classes like TextEditingController. Discouraged for app state.

Reading them

TermWhat it is
ref.watchSubscribe and rebuild/recompute on change. The default.
ref.readOne-off read, no subscription. For callbacks only - never in build.
ref.listenSide effects on change: snackbars, navigation. The BlocListener analogue.
ref.invalidateThrows the value away so it recomputes on next read.
ref.refreshInvalidate and immediately read the new value.
ref.onDisposeCleanup when the provider is destroyed.
selectSubscribe to one field, so unrelated changes do not rebuild.

Lifecycle and shape

TermWhat it is
familyParameterised providers - one per id, e.g. userProvider(userId).
autoDisposeDestroys state when nothing is listening. Default under code generation.
keepAliveOpts a provider out of auto-disposal.
AsyncValueThe data / loading / error union, exhaustively switchable.
AsyncValue.guardWraps an async call so errors land in the union instead of throwing.

Wiring and tooling

TermWhat it is
ProviderScopeRoot widget holding all provider state. Required once, at the top.
ConsumerWidgetStateless widget with a WidgetRef in build.
ConsumerStatefulWidgetThe stateful equivalent, with ref on the state class.
ConsumerScopes a rebuild to a subtree without converting the whole widget.
ProviderContainerProvider state outside the widget tree - how you test without pumping.
overridesSwap a provider's implementation, for tests, mocks, or per-flavour config.
ProviderObserverGlobal hook for provider lifecycle. Riverpod's BlocObserver analogue, less chronological.
@riverpodCode generation that writes the provider declarations and improves inference.
riverpod_lintCatches 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

BlocRiverpodNotes
BlocNotifier / AsyncNotifierRiverpod has no event concept - you call methods directly.
CubitNotifierThe closest one-to-one match in the whole table.
StateNotifier (via flutter_bloc peers)NotifierProviderStateNotifier is legacy on both sides now.
-StateProviderNo Bloc equivalent; you would write a one-field Cubit.
-FutureProvider / StreamProviderIn Bloc you emit Loading / Loaded / Error states by hand.
emit(newState)state = newStateSame idea, different ceremony.
on<Event>(handler)A method on the notifierBloc routes by event type; Riverpod calls the method.

Wiring it into the tree

BlocRiverpodNotes
BlocProviderDeclaring a provider + ProviderScopeRiverpod's providers are global; scoping is the exception, not the rule.
MultiBlocProvider(nothing needed)No nesting problem to solve - providers are declared, not mounted.
RepositoryProviderProviderSame job: expose a dependency.
BlocProvider.valueoverrides on a ProviderScopeHow you inject an existing instance.
ProviderScope at rootProviderScope at rootBloc needs no root; Riverpod requires exactly one.

Reading it in widgets

BlocRiverpodNotes
BlocBuilderref.watch(p)The everyday read.
BlocListenerref.listen(p, cb)Side effects without rebuilding.
BlocConsumerref.watch + ref.listenRiverpod does not bundle them.
BlocSelectorref.watch(p.select(...))Narrow the subscription.
buildWhenselectDifferent 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

BlocRiverpodNotes
Subscribe to another bloc in the constructorref.watch(otherProvider)This is Riverpod's headline advantage - three lines vs a subscription.
One bloc instance per id, managed manuallyfamilyRiverpod handles the keying and disposal.
close() in BlocProviderautoDisposeRiverpod disposes automatically by default under code generation.

Async, errors, and lifecycle

BlocRiverpodNotes
Loading / Loaded / Error statesAsyncValueBloc: you declare them. Riverpod: you get them.
try/catch in the handlerAsyncValue.guardBoth funnel errors into state.
emit.forEach / emit.onEachStreamProviderBridging a stream into state.
close()ref.onDisposeCleanup hook.
Event transformers (droppable, restartable)-No Riverpod equivalent. Debounce and concurrency are manual.

Tooling and testing

BlocRiverpodNotes
BlocObserverProviderObserverBloc's is chronological and event-named; Riverpod's is lifecycle-based.
Transition / ChangeProvider didUpdateProviderBloc gives you the cause; Riverpod gives you before/after.
bloc_test (blocTest)ProviderContainer + overridesRiverpod needs no dedicated test package.
hydrated_blocManual, or shared_preferences in a notifierBloc wins here - persistence is one mixin.
-riverpod_lintCatches 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.

BlocRiverpod
ArchitectureEvent-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 managementExplicit 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 injectionNot 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.
Testingbloc_test gives a tight build/act/expect harness. Excellent, but a dedicated package.ProviderContainer plus overrides - no extra package, no widget pump. Marginally simpler.
PerformanceRebuilds 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 chooseAuditable, 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.read works 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.