Flutter State Management: Riverpod vs Bloc vs Provider — When Simple is Better

Flutter State Management: Riverpod vs Bloc vs Provider — When Simple is Better

Most Flutter developers pick a state management solution before they need one. They read a Medium article, watch a conference talk, and suddenly they're wrapping a counter app in three layers of abstraction.

We've been there. At Etere Studio, we've built apps with all the major solutions — and we've also shipped production apps using nothing but setState and InheritedWidget. The right choice depends entirely on what you're building.

This guide compares Riverpod, Bloc, and Provider with actual code. More importantly, it helps you decide when you don't need any of them.


The Case for Vanilla Flutter First

Before we compare packages, let's acknowledge something the Flutter community often forgets: Flutter ships with state management built in.

setState works perfectly for local widget state. A form, a toggle, an animation controller — these don't need global state. They need a StatefulWidget and five lines of code.

For shared state across a subtree, InheritedWidget (or its friendlier wrapper InheritedModel) handles it cleanly. Yes, the boilerplate is verbose. But for one or two pieces of shared data, it's explicit and dependency-free.

We recently built a client dashboard that tracks three pieces of global state: user auth, theme preference, and a notification count. Total state management code? About 80 lines using InheritedWidget. No packages. No build runner. No learning curve for new developers.

The rule we follow: start with the simplest solution that works, then migrate when you hit friction. You'll know when setState becomes painful — it's when you're passing callbacks through four widget layers or rebuilding entire screens for tiny updates.


Provider: The Gateway Solution

Provider was the official recommendation for years, and it's still a solid choice for straightforward apps.

What It Does Well

Provider wraps InheritedWidget with a cleaner API. You get dependency injection, scoped state, and lazy initialization without writing boilerplate.

// Define your state
class CartState extends ChangeNotifier {
  final List<Product> _items = [];

  List<Product> get items => List.unmodifiable(_items);

  void add(Product product) {
    _items.add(product);
    notifyListeners();
  }
}

// Provide it
ChangeNotifierProvider(
  create: (_) => CartState(),
  child: MyApp(),
)

// Consume it
final cart = context.watch<CartState>();

That's genuinely simple. A junior developer can understand this in 20 minutes.

Where It Gets Awkward

Provider's limitations appear when apps grow:

Combining providers is verbose. Need state that depends on two other providers? You're writing ProxyProvider or Consumer nesting that gets hard to follow.

No built-in async handling. FutureProvider and StreamProvider exist, but managing loading/error states means custom logic every time.

Testing requires widget trees. You can't easily test business logic in isolation — you need to pump widgets or use ProviderScope workarounds.

The ChangeNotifier pattern leaks. Your state classes extend framework classes, mixing business logic with notification mechanics.

When Provider Fits

  • Apps with 3-5 pieces of global state
  • Teams new to Flutter who need quick wins
  • Prototypes and MVPs where speed beats architecture
  • Apps that won't grow significantly
Visual comparison of code structure complexity across Provider, Riverpod, and Bloc

Bloc: The Enterprise Choice

Bloc (Business Logic Component) enforces a strict pattern: events go in, states come out. Everything flows through streams.

What It Does Well

Bloc's rigidity is its strength. Every state change is explicit, traceable, and testable.

// Events
abstract class CartEvent {}
class AddProduct extends CartEvent {
  final Product product;
  AddProduct(this.product);
}

// States
abstract class CartState {}
class CartLoaded extends CartState {
  final List<Product> items;
  CartLoaded(this.items);
}

// Bloc
class CartBloc extends Bloc<CartEvent, CartState> {
  CartBloc() : super(CartLoaded([])) {
    on<AddProduct>((event, emit) {
      final current = state as CartLoaded;
      emit(CartLoaded([...current.items, event.product]));
    });
  }
}

More code, yes. But look what you get:

Complete audit trail. Every state change has a named event. Debugging is "what event fired?" not "where did notifyListeners get called?"

Excellent testing. Blocs are pure Dart classes. Test them without widgets:

blocTest<CartBloc, CartState>(
  'adds product to cart',
  build: () => CartBloc(),
  act: (bloc) => bloc.add(AddProduct(testProduct)),
  expect: () => [CartLoaded([testProduct])],
);

Scales predictably. The pattern stays the same whether you have 5 or 50 blocs. New team members know exactly where to look.

Where It Hurts

Boilerplate is real. A simple feature needs an event class, a state class (often multiple), and a bloc. For a counter, that's absurd.

Learning curve is steep. Streams, the Bloc pattern, Cubit vs Bloc, BlocProvider vs BlocListener vs BlocConsumer — there's a lot to internalize.

Overkill for simple apps. We've seen codebases where 60% of the code is Bloc ceremony for apps that could've been 200 lines of Provider.

When Bloc Fits

  • Large apps with complex state interactions
  • Teams that value explicit, traceable state changes
  • Apps requiring comprehensive test coverage
  • Long-term projects where maintainability beats velocity
  • Teams with backend developers who appreciate the pattern
Flowchart showing decision tree for choosing Flutter state management solution

Riverpod: The Modern Contender

Riverpod is Provider's author saying "let me fix the mistakes." It's a complete rewrite that addresses Provider's pain points.

What It Does Well

Riverpod providers are global, immutable declarations. No BuildContext required.

// Define providers anywhere
final cartProvider = StateNotifierProvider<CartNotifier, List<Product>>((ref) {
  return CartNotifier();
});

class CartNotifier extends StateNotifier<List<Product>> {
  CartNotifier() : super([]);

  void add(Product product) {
    state = [...state, product];
  }
}

// Use in widgets
class CartWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final items = ref.watch(cartProvider);
    return ListView(children: items.map(ProductTile.new).toList());
  }
}

Compile-time safety. No more runtime "ProviderNotFoundException." If a provider doesn't exist, your code won't compile.

Combining state is elegant. Need derived state? Just reference other providers:

final cartTotalProvider = Provider<double>((ref) {
  final items = ref.watch(cartProvider);
  return items.fold(0, (sum, item) => sum + item.price);
});

Async is first-class. FutureProvider and StreamProvider handle loading, error, and data states automatically:

final userProvider = FutureProvider<User>((ref) async {
  return await api.fetchUser();
});

// In widget
ref.watch(userProvider).when(
  data: (user) => UserProfile(user),
  loading: () => CircularProgressIndicator(),
  error: (e, _) => ErrorWidget(e),
);

Testing is trivial. Override any provider in tests without touching widgets:

final container = ProviderContainer(
  overrides: [cartProvider.overrideWith((ref) => MockCartNotifier())],
);

Where It Gets Complex

Multiple provider types. Provider, StateProvider, StateNotifierProvider, FutureProvider, StreamProvider, NotifierProvider, AsyncNotifierProvider… choosing the right one takes experience.

Code generation (optional but recommended). Riverpod 2.0 introduced annotations that reduce boilerplate but add a build step:

@riverpod
class Cart extends _$Cart {
  @override
  List<Product> build() => [];

  void add(Product product) {
    state = [...state, product];
  }
}

Steeper initial learning. The mental model differs from Provider. Developers need to understand ref.watch vs ref.read vs ref.listen, provider lifecycles, and scoping.

When Riverpod Fits

  • Apps with significant async operations
  • Teams comfortable with reactive programming
  • Projects where testability is a priority
  • Medium to large apps that will evolve
  • Developers who want type safety everywhere

Decision Tree: Choosing Your Solution

Here's how we decide at Etere Studio:

Is your state local to one widget?
→ Use setState. Don't overthink it.

Do you have 1-3 pieces of shared state with simple logic?
→ Start with Provider or even InheritedWidget. Migrate later if needed.

Are you building an MVP or prototype?
→ Provider. Fastest to implement, easiest to teach.

Do you have complex async flows (API calls, real-time data)?
→ Riverpod. The async handling alone is worth it.

Is your team large or the app long-lived?
→ Bloc for strict patterns, Riverpod for flexibility. Depends on team preference.

Do you need extensive testing and audit trails?
→ Bloc. The event/state pattern makes testing and debugging straightforward.

Are you already using Provider and hitting limits?
→ Riverpod. Migration is gradual — both can coexist.


The Boilerplate Comparison

Let's be concrete. Here's a simple counter in each solution:

setState: ~15 lines

class Counter extends StatefulWidget {
  @override
  _CounterState createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => setState(() => _count++),
      child: Text('$_count'),
    );
  }
}

Provider: ~25 lines (including setup)

Riverpod: ~20 lines (with code gen) or ~30 lines (without)

Bloc: ~50+ lines (events, states, bloc, widget)

For a counter, Bloc is obviously overkill. But for an app with 20 features, authentication flows, and real-time updates? The calculus changes.


Our Philosophy: Earn Your Complexity

We've shipped apps using all these solutions. The pattern we've settled on:

  1. Start simple. setState and Provider handle more than you'd think.
  2. Feel the pain first. Don't add Bloc because you might need it. Add it when Provider's limitations actually hurt.
  3. Match the team. A solo developer can thrive with Riverpod's flexibility. A 10-person team might need Bloc's guardrails.
  4. Consider the lifespan. A 3-month MVP has different needs than a 3-year product.

The best state management solution is the one your team understands and your app actually needs. Everything else is premature optimization.


Stuck choosing? We've helped teams navigate this decision dozens of times. Let's talk — even a quick conversation can save weeks of refactoring.