Flutter State Management Deep Dive — Riverpod vs BLoC vs Provider
Flutter state management debates can get heated, but the right choice depends on team size, testability needs, and async complexity. Here's an objective comparison.
Counter Example: Three Approaches
Riverpod 2.0 (my recommendation)
// providers.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
void decrement() => state--;
}
// UI
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(children: [
Text('$count'),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Text('+'),
),
]);
}
}
BLoC
// counter_bloc.dart
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<Increment>((event, emit) => emit(state + 1));
on<Decrement>((event, emit) => emit(state - 1));
}
}
sealed class CounterEvent {}
class Increment extends CounterEvent {}
class Decrement extends CounterEvent {}
// UI
BlocBuilder<CounterBloc, int>(
builder: (context, count) => Column(children: [
Text('$count'),
ElevatedButton(
onPressed: () => context.read<CounterBloc>().add(Increment()),
child: const Text('+'),
),
]),
)
Async Data: Where Riverpod Shines
// Auto-handles loading/error states
@riverpod
Future<List<Task>> tasks(TasksRef ref) async {
final client = ref.watch(supabaseProvider);
final res = await client.from('tasks').select().order('created_at');
return res.map(Task.fromJson).toList();
}
// UI: pattern-match on AsyncValue
ref.watch(tasksProvider).when(
data: (tasks) => TaskList(tasks: tasks),
loading: () => const CircularProgressIndicator(),
error: (e, _) => ErrorWidget(message: e.toString()),
)
Dependency Injection
// Riverpod: automatic dependency resolution
@riverpod
SupabaseClient supabase(SupabaseRef ref) => Supabase.instance.client;
@riverpod
TaskRepository taskRepo(TaskRepoRef ref) =>
TaskRepository(client: ref.watch(supabaseProvider));
@riverpod
Future<List<Task>> tasks(TasksRef ref) =>
ref.watch(taskRepoProvider).getAll();
// BLoC: explicit manual wiring
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => AuthBloc(repo: AuthRepository())),
BlocProvider(create: (ctx) => TaskBloc(
repo: TaskRepository(client: ctx.read<SupabaseClient>()),
)),
],
child: const App(),
)
Testing
// Riverpod: ProviderContainer, easy overrides
test('increment counter', () {
final container = ProviderContainer(
overrides: [
supabaseProvider.overrideWithValue(MockSupabaseClient()),
],
);
addTearDown(container.dispose);
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
// BLoC: bloc_test package
blocTest<CounterBloc, int>(
'emits [1] when Increment added',
build: () => CounterBloc(),
act: (bloc) => bloc.add(Increment()),
expect: () => [1],
);
Decision Matrix
| Criteria | Riverpod 2.0 | BLoC | Provider |
|---|---|---|---|
| Learning curve | Medium | High | Low |
| Testability | Excellent | Excellent | Good |
| Async handling | Excellent | Good | Poor |
| Team scalability | Excellent | Excellent | Fair |
| Boilerplate | Low (generated) | High | Low |
| Best for | Any size | Large teams | Prototypes |
My take: Riverpod 2.0 + riverpod_annotation for most projects. Code generation handles boilerplate, AsyncValue makes async UI trivial, and ProviderContainer overrides make tests clean.
Since migrating to Riverpod, state-related bugs dropped by 70%.
What state management do you use in production? Share in the comments!
Top comments (0)