Flutter State Management in 2023 — Riverpod vs Bloc vs GetX
After building three production Flutter apps, here's my honest take on the major state management solutions, when to use each, and what I actually use day-to-day.
I've built mobile apps with Flutter for Mosta9el, BaliHani, and Vexonik. Each project used a different state management approach. Here's what I learned.
The Problem With State in Flutter
Flutter's reactive model is powerful, but as apps grow, setState everywhere becomes a nightmare. When I started building Mosta9el — a freelancing platform with real-time job feeds, user profiles, messaging, and authentication — I needed something that scaled.
The Three Contenders
Riverpod — My Current Choice
Riverpod 2.0 is my go-to for new projects. It's compile-safe, testable, and the code generator removes boilerplate.
// Define a provider
@riverpod
Future<List<Job>> jobList(JobListRef ref) async {
final repo = ref.watch(jobRepositoryProvider);
return repo.fetchJobs();
}
// Consume in a widget
class JobListWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final jobs = ref.watch(jobListProvider);
return jobs.when(
data: (list) => ListView.builder(
itemCount: list.length,
itemBuilder: (_, i) => JobCard(job: list[i]),
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
);
}
}What I love: no BuildContext gymnastics, providers are lazy by default, and ref.invalidate() makes refresh trivial.
Bloc — For Complex Business Logic
I used Bloc on BaliHani, which had complex booking flows with multiple states. Bloc forces you to think in events and states, which is great for anything non-trivial.
// State
sealed class BookingState {}
class BookingInitial extends BookingState {}
class BookingLoading extends BookingState {}
class BookingConfirmed extends BookingState {
final String bookingId;
BookingConfirmed(this.bookingId);
}
class BookingFailed extends BookingState {
final String reason;
BookingFailed(this.reason);
}
// Bloc
class BookingBloc extends Bloc<BookingEvent, BookingState> {
BookingBloc(this._repo) : super(BookingInitial()) {
on<SubmitBooking>(_onSubmit);
}
Future<void> _onSubmit(
SubmitBooking event,
Emitter<BookingState> emit,
) async {
emit(BookingLoading());
try {
final id = await _repo.book(event.craftsman, event.slot);
emit(BookingConfirmed(id));
} catch (e) {
emit(BookingFailed(e.toString()));
}
}
}Bloc shines when your logic has many state transitions and you need testability. The verbosity is the price — it pays off in teams.
GetX — Fast but Dangerous
GetX is genuinely fast for prototyping. Controller registration, routing, and snackbars all in one package. I used it on a quick internal tool:
class AuthController extends GetxController {
final isLoggedIn = false.obs;
final user = Rxn<User>();
Future<void> login(String email, String pass) async {
final u = await _authService.login(email, pass);
user.value = u;
isLoggedIn.value = true;
Get.offAllNamed('/home');
}
}
// In widget
Obx(() => controller.isLoggedIn.value
? HomeScreen()
: LoginScreen())The problem: GetX mixes routing, state, DI, and utilities into one opinionated glob. Maintenance becomes painful as the app grows. I wouldn't use it for anything production-grade today.
My Decision Framework
| Scenario | Use |
|---|---|
| New production app | Riverpod |
| Complex multi-step flows / large team | Bloc |
| Quick prototype / hackathon | GetX |
| Simple local state | setState / ValueNotifier |
Real Advice
Don't over-engineer. A login form doesn't need Bloc. A simple list with pull-to-refresh doesn't need Riverpod. Start with setState, reach for Riverpod when you need to share state across widgets, and only reach for Bloc when the logic genuinely warrants the ceremony.
The biggest mistake I see: developers adopting a state management library because it's popular, then fighting it on every simple screen.