Flutter State Management Is a Mess and Nobody Will Admit It
I shipped my first production Flutter app in late 2020. By March 2021 I had rewritten the state layer twice, considered a third migration, and developed a permanent twitch whenever someone says “just use Provider, it’s simple.” It is not simple. None of this is simple. The Flutter ecosystem has convinced itself that state management is a solved problem because there are twelve incompatible solutions, each with a conference talk and a Medium article claiming victory.
This post is not a tutorial. Tutorials are how we got here. This is an autopsy.
The Four Horsemen
If you have spent any time in Flutter Discord servers or r/FlutterDev, you know the cast. Provider is the official-ish recommendation from the Flutter team, a thin wrapper over InheritedWidget that most people use wrong. BLoC (Business Logic Component) is the enterprise answer: streams, events, separation of concerns, and enough boilerplate to make a Java developer feel at home. Riverpod is Provider’s ambitious nephew who read too much functional programming and decided global state needed compile-time safety. GetX is what happens when someone optimizes for lines of code deleted and accidentally optimizes for testability deleted too.
I have used all four in production or near-production contexts. Here is what nobody says out loud.
Provider: Simple Until It Isn’t
Provider works beautifully for a counter app. You wrap your app in MultiProvider, sprinkle ChangeNotifierProvider widgets around, call context.read<Counter>().increment(), and ship to the Play Store feeling clever.
Then you add authentication. Then you add a shopping cart that depends on auth state. Then you add a nested navigator with its own providers. Then you discover that context.read inside initState throws because the widget tree isn’t ready. Then you discover that Consumer rebuilds more than you expected. Then you add Selector everywhere and your widget tree looks like a Christmas tree designed by someone who hates Christmas.
Provider’s real problem is not capability. It is scope. InheritedWidget propagates down the tree, which means you are constantly thinking about where in the widget hierarchy your state lives. Put UserProvider too high and everything rebuilds. Put it too low and half your app cannot access it. There is no compile-time enforcement. You find out at runtime, usually in QA, usually on a Tuesday.
BLoC: Correctness at What Cost
BLoC was my second attempt. I wanted testability. I wanted clear separation between UI and business logic. I got those things. I also got three files per feature: auth_event.dart, auth_state.dart, auth_bloc.dart. For a login screen.
The pattern is sound. Events flow in, states flow out, the UI is a pure function of state. Your unit tests are beautiful. Your flutter_bloc BlocBuilder widgets are clean.
// This is fine. The other 400 lines are not.
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(AuthUnauthenticated()) {
on<LoginRequested>(_onLoginRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
final user = await authRepository.login(event.email, event.password);
emit(AuthAuthenticated(user));
} catch (e) {
emit(AuthError(e.toString()));
}
}
}
The problem is velocity. BLoC was designed for teams where a senior engineer reviews every state transition. For a solo founder shipping an MVP to validate a market, BLoC is a tax you pay in files. Cubit exists and is simpler, but then you are back to “which flavor of BLoC” debates that consume more Slack messages than the actual feature work.
Riverpod: The Smart Choice That Requires You to Be Smart
Riverpod fixes Provider’s scoping problems with ProviderScope, ref.watch, and providers that are not tied to the widget tree. Compile-time safety via Provider types. No BuildContext in your business logic. Async providers with built-in loading and error states.
I like Riverpod. I genuinely do. It is the best-designed of the four.
It is also the hardest to onboard. Provider, StateNotifierProvider, FutureProvider, StreamProvider, family, autoDispose, override for testing. The mental model is excellent once you have it. Getting there requires reading documentation that assumes you already understand the problem Riverpod is solving, which you do not, because you came from Provider.
GetX: Speedrun Any% Wrong Architecture
GetX deletes boilerplate. Get.put(Controller()), Obx(() => Text(controller.count.value)), done. Navigation, dependency injection, state management, snackbars, dialogs, all in one package. It is seductive.
I used GetX on a prototype. It was fast. It was also a nightmare to test, a nightmare to reason about when controllers outlived their routes, and a nightmare to untangle when I needed to migrate. Get.find<SomeController>() is global mutable state with extra steps. The GetX community will tell you that you are holding it wrong. They are partially right and entirely unhelpful.
The Diagram Everyone Needs and Nobody Draws
Here is what actually happens in a typical Flutter app with Provider versus BLoC. Not the marketing diagram. The real one.
flowchart TB
subgraph Provider Flow
UI1[Widget Tree] -->|context.watch| CN[ChangeNotifier]
CN -->|notifyListeners| UI1
UI1 -->|context.read| CN
CN -->|async API call| API1[Backend]
API1 -->|setState via notifyListeners| CN
end
subgraph BLoC Flow
UI2[Widget] -->|add event| BLoC[BLoC]
BLoC -->|emit state| UI2
BLoC -->|repository call| REPO[Repository]
REPO -->|Future/Stream| BLoC
BLoC -->|map to state| UI2
end
Notice the asymmetry. Provider couples your widget tree to your state. BLoC decouples them but inserts a ceremony layer. Neither diagram shows the bug where your ListView rebuilds because someone called notifyListeners on a provider three levels up. BLoC’s diagram does not show the BlocProvider you forgot to wrap around a route.
What I Actually Recommend (Reluctantly)
If you are a solo developer shipping an MVP: use Riverpod and accept the learning curve. The upfront cost pays off when you add features six months later and your state does not spaghetti.
If you are on a team with Java or Android architecture experience: BLoC is fine. Your team already thinks in events and states. Do not let anyone convince you that Cubit is “not real BLoC.” Ship the product.
If you are learning Flutter: start with Provider on a toy project, then migrate to Riverpod before you ship anything real. Treat Provider as training wheels, not a destination.
If someone suggests GetX for a production app with more than two screens: ask them what their test coverage looks like. Then ask again.
The Deeper Problem
State management in Flutter is a mess because Flutter itself conflates two things: ephemeral state (text field contents, animation controllers, scroll positions) and app state (user session, cached data, feature flags). The framework gives you StatefulWidget for the first and a dozen third-party libraries for the second, with no clear boundary.
React had the same problem and mostly solved it with hooks plus a single dominant library (Redux, then Context, now Zustand or Jotai depending on who you ask). Flutter has not converged. Google will not pick a winner because picking a winner means deprecating something, and the Flutter team is allergic to breaking changes that affect Medium tutorial authors.
What I Would Do Differently
I would pick Riverpod on day one and never touch Provider except to read legacy code. I would use flutter_bloc only if my co-founder insisted and had a background in enterprise Android. I would never use GetX in anything I expected to maintain past three months.
I would also stop reading “State Management in Flutter: Which One Should You Choose?” articles written by people who implemented a todo app in each framework over a weekend and declared a winner based on line count.
State management is not solved. It is managed. Pick your poison, document your choice in the README, and get back to building something users care about. That is the only advice that survived contact with production.