Flutter State Management in 2026: Riverpod vs Bloc vs Provider in Production
Why state management is the architecture decision that matters most
In most application frameworks, state management is one of several important decisions. In Flutter, it's disproportionately important, because Flutter's entire rendering model is built around widget rebuilds driven by state changes. Get state management wrong and you don't just get messy code - you get a slow app, because unnecessary rebuilds are a real performance cost, not just an aesthetic one.
There are three problems every state management approach in Flutter has to solve:
- Where does state live, and how does a widget deep in the tree access state that was created somewhere else?
- How do you know when to rebuild, and how do you avoid rebuilding widgets that don't actually depend on the state that changed?
- How do you test business logic without dragging the entire widget tree into your test suite?
setState answers none of these questions at scale - it's fine for a single screen holding its own transient state, and we still use it for exactly that. Everything past that point requires a real answer, and Provider, Bloc, and Riverpod each answer these three questions differently enough that the choice actually matters.
Provider: the pragmatic baseline
Provider is, at its core, a wrapper around Flutter's InheritedWidget that makes dependency injection and state propagation tolerable to write by hand. When Provider became the officially recommended approach a few years back, it was a genuine improvement over raw InheritedWidget boilerplate, and for small to mid-sized apps, it still holds up.
A typical Provider setup looks like this:
class CartModel extends ChangeNotifier {
final List<CartItem> _items = [];
List<CartItem> get items => List.unmodifiable(_items);
double get total => _items.fold(0, (sum, item) => sum + item.price * item.quantity);
void add(CartItem item) {
_items.add(item);
notifyListeners();
}
void remove(String itemId) {
_items.removeWhere((item) => item.id == itemId);
notifyListeners();
}
}
You register it above the widgets that need it:
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const CheckoutScreen(),
)
And consume it with context.watch<CartModel>() or a Consumer widget. It's readable, it's approachable for developers coming from a general OOP background, and there's essentially zero ceremony to get started.
Where it breaks down in production is scale and testability. ChangeNotifier rebuilds every listener when anything in the model changes - there's no fine-grained subscription to a single field without manually splitting your model into smaller ChangeNotifier classes, which multiplies the number of providers you have to wire up. We've inherited Provider-based codebases where a single unrelated field update (say, a "last synced" timestamp) triggered a full rebuild of a product listing screen with forty cards, because everything was jammed into one ChangeNotifier. Fixing that required a genuine refactor, not a config tweak.
Testing is the other soft spot. Because Provider leans on BuildContext to resolve dependencies, unit-testing business logic in isolation from the widget tree takes extra scaffolding - you end up needing ProviderContainer-like test harnesses or widget tests where plain unit tests would do. It's not impossible, it's just friction that compounds as the test suite grows.
Provider still earns its place on small apps, internal tools, and MVPs where developer velocity matters more than long-term scalability. We reach for it when a client needs something shipped in weeks, not months, and the app is genuinely small enough that the rebuild-granularity problem won't bite.
Bloc: discipline as a feature
Bloc (and its lighter sibling, Cubit) takes the opposite philosophical stance: instead of making state management easy to get started with, it makes it hard to get wrong. Every state transition happens through an explicit event-to-state mapping, and the pattern forces a strict separation between UI and business logic that Provider leaves optional.
abstract class CartEvent {}
class AddItem extends CartEvent {
final CartItem item;
AddItem(this.item);
}
class RemoveItem extends CartEvent {
final String itemId;
RemoveItem(this.itemId);
}
class CartState {
final List<CartItem> items;
final double total;
const CartState({required this.items, required this.total});
}
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc() : super(const CartState(items: [], total: 0)) {
on<AddItem>((event, emit) {
final updated = [...state.items, event.item];
emit(CartState(items: updated, total: _calculateTotal(updated)));
});
on<RemoveItem>((event, emit) {
final updated = state.items.where((i) => i.id != event.itemId).toList();
emit(CartState(items: updated, total: _calculateTotal(updated)));
});
}
double _calculateTotal(List<CartItem> items) =>
items.fold(0, (sum, item) => sum + item.price * item.quantity);
}
The immediate objection every team raises the first time they see this is boilerplate - and it's a fair complaint if you're building a prototype. But that boilerplate buys something real: every state transition is a named, traceable event. When a support ticket comes in describing a bug three states deep in a checkout flow, you can trace the exact sequence of events that produced the broken state, because the event log is the audit trail. We've used bloc_test and the built-in BlocObserver to catch production bugs that would have taken days to reproduce with an ad hoc setState-based flow, because we could literally replay the event sequence.
Bloc's testability is its strongest production argument. Because business logic lives entirely outside the widget tree, in a class that takes events in and emits states out, you can unit test it with zero widget dependencies:
blocTest<CartBloc, CartState>(
'emits updated cart when item is added',
build: () => CartBloc(),
act: (bloc) => bloc.add(AddItem(testItem)),
expect: () => [
CartState(items: [testItem], total: testItem.price),
],
);
On a fintech project with strict audit requirements, this traceability wasn't optional - it was a compliance requirement, and Bloc's structure made satisfying it straightforward rather than a bolted-on afterthought.
The cost is genuine, though. Bloc has a real learning curve for developers new to reactive patterns, and on smaller features it can feel like ceremony for its own sake - defining an event class and a state class for a toggle that could have been three lines of setState is a legitimate overhead complaint. Teams that adopt Bloc wholesale, including for trivial local UI state, tend to burn out on the pattern and start cutting corners inconsistently, which is worse than not adopting it at all.
Riverpod: what Provider was trying to be
Riverpod, built by the same author as Provider, is best understood as a rewrite that fixes Provider's structural problems rather than a competing philosophy. The core change: providers are no longer tied to BuildContext or the widget tree at all. They're declared globally as compile-time-safe objects, and Flutter widgets just happen to be one of the ways you can consume them.
final cartProvider = StateNotifierProvider<CartNotifier, CartState>((ref) {
return CartNotifier();
});
class CartNotifier extends StateNotifier<CartState> {
CartNotifier() : super(const CartState(items: [], total: 0));
void add(CartItem item) {
final updated = [...state.items, item];
state = CartState(items: updated, total: _total(updated));
}
void remove(String itemId) {
final updated = state.items.where((i) => i.id != itemId).toList();
state = CartState(items: updated, total: _total(updated));
}
double _total(List<CartItem> items) =>
items.fold(0, (sum, item) => sum + item.price * item.quantity);
}
Consumption happens through ConsumerWidget or ref.watch(cartProvider), and critically, Riverpod lets you scope your .select() calls down to a single field so a widget only rebuilds when the specific value it cares about changes - solving Provider's biggest rebuild-granularity complaint without requiring you to split every model into a dozen smaller classes.
The dependency-injection story is where Riverpod pulls ahead for larger codebases. Providers can depend on other providers, Riverpod catches missing or circular dependencies at compile time rather than at runtime with an exception buried in a stack trace, and - probably the most underrated feature - providers can be overridden per test or per widget subtree, which makes mocking dramatically simpler than either Provider or Bloc:
testWidgets('shows cart total', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
cartProvider.overrideWith((ref) => CartNotifier()..add(testItem)),
],
child: const MaterialApp(home: CheckoutScreen()),
),
);
expect(find.text('\$24.99'), findsOneWidget);
});
On a marketplace app we built with a genuinely large provider graph - user session, cart, inventory availability, promotions, shipping estimates, all depending on each other in different combinations per screen - Riverpod's explicit dependency graph and compile-time safety caught several bugs during development that would have been runtime crashes in a Provider-based equivalent, simply because a typo in a provider reference is now a compile error instead of a null exception three screens later.
Riverpod isn't free of tradeoffs. Code generation (riverpod_generator) is close to mandatory for keeping the boilerplate manageable at scale, which means an extra build step and occasional build_runner friction that trips up developers new to the ecosystem. And because Riverpod is younger than Provider and less rigidly patterned than Bloc, we've seen more inconsistency across different developers' Riverpod code on the same team than we see with Bloc, where the pattern is strict enough to enforce itself.
The decision framework we actually use
Stripped of ideology, here's the framework we walk clients through:
Team size and experience matters more than the tool. A team of two or three developers who know each other's code well can move fast with any of these three. A team of eight across two time zones needs the enforced consistency that Bloc's rigid structure provides, because the alternative is eight different interpretations of "the Riverpod way."
Compliance and audit requirements point toward Bloc. If you're in fintech, healthcare, or another regulated space where you need to demonstrate exactly how an application state was reached - for a security review, an audit, or a bug postmortem - Bloc's event-sourcing-adjacent structure gives you that for free. This isn't theoretical for us; it's been the deciding factor on more than one project.
Long-lived apps with growing complexity point toward Riverpod. If you're building something you expect to still be maintaining in three years, with a provider graph that will only grow, Riverpod's compile-time dependency safety and fine-grained rebuild control pay for their learning curve many times over. The apps where we've seen the most "how did this ever work" moments are large Provider codebases that outgrew the pattern without anyone deciding to migrate.
Speed-to-market points toward Provider, with eyes open. If the goal is an MVP to validate a concept, and the app genuinely might get thrown away or heavily rewritten after user testing, Provider's low ceremony is the right call. The mistake is not revisiting that decision once the MVP becomes the real product - we've seen founders keep shipping features onto a Provider foundation two years past the point where it stopped fitting, because nobody wanted to schedule the migration.
Mixed approaches are underrated. We've shipped apps using Bloc for the core transactional flows (checkout, payments, auth) where traceability matters, and simple
ValueNotifieror localsetStatefor cosmetic UI state like expanded/collapsed cards. Dogmatically applying one pattern everywhere is a code-review preference, not an architecture requirement.
Migration is real work - budget for it honestly
Every few months we get a version of the same request: "Can you migrate our Provider app to Riverpod?" The honest answer is that it's rarely a mechanical find-and-replace, because the mental model shift (context-scoped state versus globally-declared providers) usually surfaces places where the original state architecture was already leaking - a ChangeNotifier that was doing too much, a dependency that was implicitly shared when it shouldn't have been, or a widget that was relying on a rebuild side effect instead of a real state transition.
Comments
No comments yet. Start the discussion.