Flutter State Management Comparison 2024 — Riverpod vs Bloc vs Provider vs GetX
Every Flutter developer eventually faces the same question: which state management library should I use? This article compares the four dominant options from a production perspective, covering learning curve, testability, boilerplate, and compatibility with Supabase.
Feature Comparison Table
| Criteria | Riverpod 2.0 | Bloc | Provider | GetX |
|---|---|---|---|---|
| Learning Curve | Medium | High | Low | Low–Medium |
| Testability | Excellent | Excellent | Good | Fair |
| Boilerplate | Low | High | Low | Low |
| Compile-time Safety | Excellent | Good | Fair | Fair |
| Community Size | Large | Large | Large (legacy) | Medium |
| Type-safe Async | Excellent (AsyncValue) | Good | Fair | Fair |
| DevTools Support | Excellent | Excellent | Good | Fair |
Why Riverpod 2.0 Is the Current Recommendation
Riverpod 2.0 with code generation via @riverpod annotations is stable and production-ready. AsyncNotifier handles async state with full type safety, and integration with Supabase streams is natural.
// Provider that fetches user profiles from Supabase
@riverpod
Future<List<UserProfile>> userProfiles(UserProfilesRef ref) async {
final supabase = ref.watch(supabaseClientProvider);
final response = await supabase
.from('profiles')
.select()
.order('created_at', ascending: false);
return response.map((json) => UserProfile.fromJson(json)).toList();
}
// Usage in a Widget
class UserListPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(userProfilesProvider);
return usersAsync.when(
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (ctx, i) => UserTile(user: users[i]),
),
loading: () => const CircularProgressIndicator(),
error: (e, st) => ErrorWidget(e.toString()),
);
}
}
ref.invalidate() allows you to manually refresh cached data, making it easy to refetch after Supabase INSERT/UPDATE/DELETE operations without any boilerplate refresh logic.
When Bloc Makes Sense
Bloc is the right choice for large teams and strict event-driven architectures. The explicit Event → State flow makes code reviews straightforward and intent transparent.
// Event definition
abstract class AuthEvent {}
class LoginRequested extends AuthEvent {
final String email;
final String password;
LoginRequested({required this.email, required this.password});
}
// Bloc implementation
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc(this._supabase) : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
await _supabase.auth.signInWithPassword(
email: event.email,
password: event.password,
);
emit(AuthSuccess());
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
}
For teams of 5+ developers working on feature branches in parallel, Bloc's explicit structure reduces merge conflicts and makes ownership of state transitions clear.
The Risks of GetX
GetX is attractive for its low boilerplate, but carries significant risks in production:
- Monolithic API: state management, routing, and DI are bundled together, making partial adoption difficult
-
Global state pollution:
Get.find()creates implicit global dependencies that are hard to mock in tests - Maintenance uncertainty: community fragmentation and delayed compatibility with new Flutter versions
GetX may be acceptable for throwaway prototypes, but avoid it for any product you plan to maintain long-term.
Migrating from Provider to Riverpod
If you're using ChangeNotifierProvider, migration can be done incrementally:
// Before: Provider
class UserNotifier extends ChangeNotifier {
UserProfile? _user;
UserProfile? get user => _user;
Future<void> loadUser(String id) async {
_user = await supabase.from('profiles').select().eq('id', id).single();
notifyListeners();
}
}
// After: Riverpod (AsyncNotifier)
@riverpod
class UserNotifier extends _$UserNotifier {
@override
FutureOr<UserProfile?> build(String id) async {
return ref.watch(supabaseClientProvider)
.from('profiles')
.select()
.eq('id', id)
.single()
.then((json) => UserProfile.fromJson(json));
}
}
A safe migration strategy: new features use Riverpod, existing code stays on Provider. ProviderScope and MultiProvider can coexist in the same widget tree during the transition.
Supabase Compatibility Summary
Supabase's stream() API integrates most naturally with Riverpod's StreamProvider. Bloc can manage StreamSubscription inside on<> handlers, but requires more code. Provider's StreamProvider works but lacks the built-in error/loading handling that Riverpod provides out of the box.
Recommendation: Riverpod 2.0 for solo developers and small-to-mid teams. Consider Bloc if you have 5+ developers and need strict event-driven architecture enforcement.
This series covers Flutter, Supabase, and indie SaaS development. New articles every week.
Top comments (0)