Riverpod vs BLoC: How I Choose Flutter State Management in Production
Riverpod vs BLoC for Flutter state management: the same feature built both ways, how each is tested, and the questions I ask before picking one for a project.

Every Flutter project hits the same question in its first week: Riverpod or BLoC? Teams can argue about it for days. Usually they end up picking whatever the loudest person used last.
I’ve shipped production apps with both, and inherited codebases using both. My honest answer is that either one works, but they push a team in different directions. Choosing well is less about which library is “better” and more about who will be maintaining the code a year from now.
So instead of another feature checklist, I’ll build the same small feature both ways, show how each gets tested, and walk through how I actually decide.
The feature: a list of orders
It’s deliberately boring. Load a list of orders from a repository, show a loading state, show an error with a retry, and let the user refresh. Most screens in most apps look exactly like this.
Both versions use the same repository:
abstract interface class OrderRepository {
Future<List<Order>> fetchOrders();
}
The Riverpod version
With Riverpod, an AsyncNotifier covers this feature neatly. build() does the initial load, and Riverpod wraps the result in an AsyncValue that is already loading, data, or error. You don’t have to write any state classes.
final orderRepositoryProvider = Provider<OrderRepository>(
(ref) => HttpOrderRepository(ref.watch(apiClientProvider)),
);
class OrdersNotifier extends AsyncNotifier<List<Order>> {
@override
Future<List<Order>> build() {
return ref.watch(orderRepositoryProvider).fetchOrders();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(orderRepositoryProvider).fetchOrders(),
);
}
}
final ordersProvider =
AsyncNotifierProvider<OrdersNotifier, List<Order>>(OrdersNotifier.new);
The widget watches the provider and handles the three states with when:
class OrdersScreen extends ConsumerWidget {
const OrdersScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final orders = ref.watch(ordersProvider);
return orders.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => RetryView(
onRetry: () => ref.read(ordersProvider.notifier).refresh(),
),
data: (items) => RefreshIndicator(
onRefresh: () => ref.read(ordersProvider.notifier).refresh(),
child: OrderList(items: items),
),
);
}
}
The rule that trips people up is this: ref.watch in build methods, ref.read in callbacks. Watching inside a button handler sets up subscriptions you don’t want. Reading inside build means the widget won’t rebuild when the data changes.
For synchronous state like a filter toggle or a selected tab, a plain Notifier with NotifierProvider works the same way, minus the Future.
The BLoC version
With flutter_bloc I’d use a Cubit for this. A full Bloc with events is worth it when you need event transformations such as debouncing a search field. For “load and refresh”, a Cubit is plenty.
BLoC makes you spell out the states. I use a sealed class, which lets the compiler check that the UI handles every case:
sealed class OrdersState {
const OrdersState();
}
final class OrdersLoading extends OrdersState {
const OrdersLoading();
}
final class OrdersLoaded extends OrdersState {
const OrdersLoaded(this.orders);
final List<Order> orders;
}
final class OrdersFailed extends OrdersState {
const OrdersFailed(this.message);
final String message;
}
class OrdersCubit extends Cubit<OrdersState> {
OrdersCubit(this._repository) : super(const OrdersLoading());
final OrderRepository _repository;
Future<void> load() async {
emit(const OrdersLoading());
try {
emit(OrdersLoaded(await _repository.fetchOrders()));
} catch (e) {
emit(const OrdersFailed('Could not load orders'));
}
}
}
The widget side uses BlocProvider to create the Cubit and BlocBuilder to render it:
BlocProvider(
create: (context) => OrdersCubit(context.read<OrderRepository>())..load(),
child: BlocBuilder<OrdersCubit, OrdersState>(
builder: (context, state) => switch (state) {
OrdersLoading() => const Center(child: CircularProgressIndicator()),
OrdersFailed() => RetryView(
onRetry: () => context.read<OrdersCubit>().load(),
),
OrdersLoaded(:final orders) => RefreshIndicator(
onRefresh: () => context.read<OrdersCubit>().load(),
child: OrderList(items: orders),
),
},
),
)
It’s more code, no question. But all of it is out in the open. A new developer can read OrdersState and know every state this screen can be in, without needing to know how AsyncValue works.
Testing each one
This is where the two libraries are most alike, and both are good.
In Riverpod, you create a ProviderContainer and override the repository with a fake:
test('loads orders', () async {
final container = ProviderContainer(
overrides: [
orderRepositoryProvider.overrideWithValue(FakeOrderRepository()),
],
);
addTearDown(container.dispose);
final orders = await container.read(ordersProvider.future);
expect(orders, hasLength(3));
});
With BLoC, bloc_test lets you describe the expected sequence of states:
blocTest<OrdersCubit, OrdersState>(
'emits loading then loaded',
build: () => OrdersCubit(FakeOrderRepository()),
act: (cubit) => cubit.load(),
expect: () => [isA<OrdersLoading>(), isA<OrdersLoaded>()],
);
One gotcha: expect compares states with ==. If your states don’t implement equality (by hand, with equatable, or with freezed), compare them with matchers like isA<T>() as I did above, or the test will fail even though the states look identical.
Both approaches keep business logic testable without pumping widgets, and that’s the part that really matters. I cover the bigger picture in my Flutter testing strategy post.
How I actually decide
| Question | Leans Riverpod | Leans BLoC |
|---|---|---|
| Team size and turnover | Small, experienced team | Larger team, rotating devs |
| How much boilerplate is acceptable | Less is better | Explicit is better |
| Dependency injection | Built in (providers are DI) | Pair with RepositoryProvider or get_it |
| Event-heavy logic (debounce, sequencing) | Doable, more manual | First-class with Bloc transformers |
| Lots of derived/combined state | Very natural (ref.watch other providers) |
Possible, more wiring |
| Existing codebase convention | Whatever is already there | Whatever is already there |
The last row matters more than the rest put together. When I’m taking over an existing Flutter app, I don’t migrate state management unless it’s actively causing bugs. A codebase that is half BLoC and half Riverpod is worse than either one done consistently.
For new projects, I lean Riverpod when the team is small and the app has lots of derived state, like a cart total that depends on items, discounts and the user’s region. I lean BLoC when several developers will work on the code over a long period. Its ceremony stops being overhead and becomes documentation.
What matters more than the library
The library is the least important part of the decision. A few habits matter more:
- Keep business logic out of widgets. Logic in a
Notifieror aCubitcan be tested. Logic in anonPressedclosure cannot. - Put a repository between state and data. State classes shouldn’t know whether data comes from HTTP, Firebase or a local database. That boundary is what makes offline-first caching possible later.
- Model every state, including the boring ones. Empty lists, partial failures and “refreshing while showing old data” are where bugs live.
- Scope state deliberately. Global state for things that really are global (auth, settings), screen-scoped state for everything else.
- Be consistent. One pattern, written down, applied everywhere.
I go further into layering in pragmatic clean architecture in Flutter. Both Riverpod and BLoC fit into that structure without trouble.
Takeaways
- Riverpod and BLoC are both production-ready. Pick based on your team, not on benchmarks.
- Riverpod’s
AsyncNotifierremoves boilerplate. BLoC’s explicit states make the code self-documenting. ref.watchin build,ref.readin callbacks. Most Riverpod bugs I see break this rule.- In
bloc_test, give your states equality or match withisA. - Never mix both in one codebase without a migration plan.
- Clean boundaries between UI, state and data matter more than which library sits in the middle.
If you’re starting a Flutter project and want a second opinion on its architecture before the first line of state code, get in touch.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).