Flutter State Management: Riverpod vs Bloc
State management is one of the most hotly debated topics in the Flutter ecosystem. I've shipped apps with both Riverpod and Bloc and I'll give you a frank, practical comparison based on real experience โ not just theory.
The Problem with setState
setState is fine for simple, isolated widget state. But as your app grows:
- You need to share state across many widgets
- Business logic ends up inside
build()methods - Testing becomes painful
- The widget tree rebuilds too aggressively
You need a dedicated state management solution.
Riverpod โ Modern & Compile-Safe
Riverpod is the spiritual successor to Provider, rebuilt from scratch. It's compile-safe: if you reference a provider that doesn't exist, you get a compile error โ not a runtime crash.
Setting Up
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
dev_dependencies:
riverpod_generator: ^2.4.3
build_runner: ^2.4.9
Wrap your app:
void main() {
runApp(const ProviderScope(child: MyApp()));
}
Defining a Provider
// providers/counter_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'counter_provider.g.dart';
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
void decrement() => state--;
}
Generate the boilerplate:
dart run build_runner watch
Consuming in a Widget
class CounterScreen extends ConsumerWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Scaffold(
body: Center(child: Text('$count', style: const TextStyle(fontSize: 48))),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Icon(Icons.add),
),
);
}
}
๐ก Tip: Use ref.watch for reactive values and ref.read for one-shot actions (inside callbacks). Mixing them up is the #1 Riverpod beginner mistake.
Async Providers
@riverpod
Future<List<User>> users(UsersRef ref) async {
final api = ref.watch(apiServiceProvider);
return api.fetchUsers();
}
// In widget:
final asyncUsers = ref.watch(usersProvider);
return asyncUsers.when(
data: (users) => ListView.builder(...),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
);
Bloc โ Explicit & Predictable
Bloc enforces a strict Event โ State flow. Every state change is triggered by a named event, every state is an immutable class. The traceability is unmatched.
Setting Up
dependencies:
flutter_bloc: ^8.1.6
equatable: ^2.0.5
Defining Events & States
// bloc/auth_event.dart
abstract class AuthEvent {}
class LoginRequested extends AuthEvent {
final String email, password;
LoginRequested(this.email, this.password);
}
class LogoutRequested extends AuthEvent {}
// bloc/auth_state.dart
abstract class AuthState extends Equatable {
@override List<Object> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
AuthAuthenticated(this.user);
@override List<Object> get props => [user];
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
@override List<Object> get props => [message];
}
The Bloc
// bloc/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _repo;
AuthBloc(this._repo) : super(AuthInitial()) {
on<LoginRequested>(_onLogin);
on<LogoutRequested>(_onLogout);
}
Future<void> _onLogin(LoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final user = await _repo.login(event.email, event.password);
emit(AuthAuthenticated(user));
} catch (e) {
emit(AuthError(e.toString()));
}
}
void _onLogout(LogoutRequested event, Emitter<AuthState> emit) {
emit(AuthInitial());
}
}
Consuming in a Widget
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthLoading) return const CircularProgressIndicator();
if (state is AuthAuthenticated) return HomeScreen(user: state.user);
if (state is AuthError) return ErrorWidget(state.message);
return const LoginScreen();
},
)
Head-to-Head Comparison
| Feature | Riverpod | Bloc |
|---|---|---|
| Boilerplate | Low | High |
| Learning curve | Moderate | Steep |
| Compile safety | โ Yes | โ Yes |
| Testability | Excellent | Excellent |
| Async support | Built-in | Manual |
| State traceability | Moderate | Excellent |
| Code generation | Optional | Optional |
| Community | Growing fast | Very mature |
Which Should You Choose?
- Choose Riverpod if: you want less boilerplate, you're building a new app, or you're a solo developer.
- Choose Bloc if: you're working in a large team, you need strict audit trails of state changes, or your app has complex state machines.
โน๏ธ Note: Both Riverpod and Bloc are production-proven and supported by active communities. The "best" choice is the one your team understands and maintains consistently.
Key Takeaways
setStatedoesn't scale past simple widget-local state- Riverpod's code generation reduces boilerplate while keeping type safety
- Bloc's event-driven pattern excels in complex, team-built applications
- Both integrate well with dependency injection and testing frameworks
- Pick one and stick to it โ inconsistent state management is worse than any specific choice