Skip to content
Blog

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

CriterionRiverpod 3.xBloc 9.x
Learning curveModerateSteeper
BoilerplateLow (codegen)High
TestabilityExcellentExcellent
CommunityGrowing fastLarge, enterprise
ReusabilityProvider graphStream-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