State Management in Flutter: Riverpod vs. Bloc
A hands-on comparison of Riverpod 3.x and Bloc 9.x for Flutter state management, building and testing the same counter and async-list features in both, with guidance on when to choose each.
Published on • August 12, 2026
AI Assistant

Every Flutter app eventually needs shared state, and two libraries dominate: Riverpod and Bloc. Riverpod (3.x) is a compile-safe, reactive graph of providers with optional code generation. Bloc (9.x) is a predictable, event-driven pattern separating events, states, and transitions. We build the same counter and the same async list in both, test each, and compare — code first, opinions last.
Prerequisites
You need Flutter 3.x and Dart 3.x, comfort with Future/await and StatelessWidget, plus flutter_riverpod (3.x), flutter_bloc (9.x), and bloc_test.
The Problem: Where Does State Live?
A widget tree describes UI, not business logic. State needs a home outside the tree that can hold mutable state, rebuild only the widgets depending on the changed slice, be tested without pumping widget trees, and scale from a counter to cached, async data. Riverpod and Bloc answer these differently — which is exactly why they teach you different things.
Riverpod: Reactive Providers
ProviderScope wraps the app and stores all provider state; widgets become ConsumerWidget and read providers via ref.watch. FutureProvider wraps a Future and exposes an AsyncValue modeling loading, error, and data:
@riverpod
Future<List<String>> userNames(Ref ref) async =>
ref.watch(apiClientProvider).fetchUserNames();
ref.watch subscribes; ref.read reads once. In callbacks always use read — listening from a callback leaks memory. Inside any ConsumerWidget, switch over the watched AsyncValue:
class UserList extends ConsumerWidget {
const UserList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) =>
switch (ref.watch(userNamesProvider)) {
AsyncData(:final value) => ListView(
children: [for (final n in value) ListTile(title: Text(n))]),
AsyncError(:final error) => Center(child: Text('Error: $error')),
_ => const Center(child: CircularProgressIndicator()),
};
}
Notifier and AsyncNotifier (3.x)
The recommended way to hold mutable state (AsyncNotifier for async init). With riverpod_generator the provider is compile-time typed:
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
void reset() => state = 0;
}
Run dart run build_runner watch -d to generate counter.g.dart and counterProvider — a typo’d provider name becomes a compile error. Consume it with ref.watch(counterProvider) and mutate in callbacks via ref.read(counterProvider.notifier).increment().
Bloc: Event-Driven States
Bloc splits events (what happened), states (what the UI renders), and the Bloc mapping events to states. Cubit is the event-free variant; Bloc adds an explicit onTransition audit trail. The counter:
abstract class CounterEvent {}
class CounterIncremented extends CounterEvent {}
class CounterReset extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncremented>((event, emit) => emit(state + 1));
on<CounterReset>((event, emit) => emit(0));
}
}
BlocProvider provides (and closes) instances; BlocBuilder rebuilds on new states; context.read sends events without listening, context.watch rebuilds the widget:
BlocProvider<CounterBloc>(
create: (context) => CounterBloc(),
child: BlocBuilder<CounterBloc, int>(
builder: (_, count) => Center(child: Text('$count'))),
);
context.read<CounterBloc>().add(CounterIncremented());
Async screens are modeled by designing the state classes, then rendering with a switch:
sealed class UsersState {}
class UsersLoading extends UsersState {}
class UsersError extends UsersState { UsersError(this.message); final String message; }
class UsersLoaded extends UsersState { UsersLoaded(this.items); final List<String> items; }
class UsersBloc extends Bloc<UsersEvent, UsersState> {
UsersBloc(this._repo) : super(const UsersLoading()) {
on<UsersFetchRequested>((event, emit) async {
emit(const UsersLoading());
try {
emit(UsersLoaded(await _repo.fetchUserNames()));
} on Exception catch (e) {
emit(UsersError(e.toString()));
}
});
}
}
Testing Both
Riverpod tests run in a ProviderContainer, no widgets required:
test('counter increments and resets', () {
final c = ProviderContainer();
addTearDown(c.dispose);
c.read(counterProvider.notifier).increment();
expect(c.read(counterProvider), 1);
});
Bloc tests use the bloc_test DSL, asserting the exact emitted sequence with nothing extra afterward:
blocTest<CounterBloc, int>(
'increment and reset',
build: () => CounterBloc(),
act: (bloc) => bloc.add(CounterIncremented()),
expect: () => [1, 0],
);
Choosing Between Them
| Criterion | Riverpod 3.x | Bloc 9.x |
|---|---|---|
| Learning curve | Moderate | Steeper |
| Boilerplate | Low (codegen) | High |
| Testability | Excellent | Excellent |
| Community | Growing fast | Large, enterprise |
| Reusability | Provider graph | Stream-based |
Choose Riverpod for minimal ceremony, codegen compile-time safety, automatic loading/error handling, and granular rebuilds. Choose Bloc for an explicit, traceable event log — ideal when features need audit trails. Cubit offers a low-friction Bloc entry point.
Putting It All Together
The same screen — a counter plus an async user list from a shared UserRepository:
@riverpod
class Dashboard extends _$Dashboard {
Future<DashboardData> build() async {
final api = ref.watch(apiClientProvider);
return DashboardData(count: 0, users: await api.fetchUserNames());
}
}
class DashboardBloc extends Bloc<DashboardEvent, DashboardState> {
DashboardBloc(this._repo) : super(const DashboardLoading()) {
on<DashboardStarted>((event, emit) async {
emit(const DashboardLoading());
try {
emit(DashboardLoaded(0, await _repo.fetchUserNames()));
} on Exception catch (e) {
emit(DashboardError(e.toString()));
}
});
}
}
Wrap the Riverpod widget in ProviderScope, the Bloc widget in BlocProvider(create: (context) => DashboardBloc(repo)), then render with AsyncValue/BlocBuilder — the consumer code looks remarkably similar.
Conclusion & Next Steps
Both are production-grade and growing closer: Riverpod 3 borrows structure via Notifier and codegen, while Bloc 9 keeps its predictable core. Start with Riverpod for velocity and compile safety; adopt Bloc when you need an explicit, replayable event trail. Build the same feature in both on a real screen — the code, not the hype, should decide.
References / Sources
- Riverpod — getting started: https://riverpod.dev/docs/introduction/getting_started
- Riverpod — (Async)NotifierProvider reference: https://docs-v2.riverpod.dev/docs/providers/notifier_provider
- Bloc official docs — Core Concepts (Bloc vs Cubit): https://bloclibrary.dev/#/coreconcepts?id=cubit-vs-bloc
- flutter_bloc package: https://pub.dev/packages/flutter_bloc
- flutter_riverpod package: https://pub.dev/packages/flutter_riverpod
- Flutter docs — state management options: https://docs.flutter.dev/data-and-backend/state-mgmt/options