
Flutter State Management in a Real App — What I Actually Use and Why
Not another Riverpod vs Bloc overview. A candid look at the state management decisions I've made across multiple production Flutter apps, and what the tradeoffs looked like in practice.
If you've been in the Flutter world for more than a week, you've already encountered the state management debate. Riverpod or Bloc? GetX or Provider? Is setState really that bad? The Flutter community debates this endlessly, but most of the answers online are written by people evaluating solutions in toy apps — a counter, a todo list, a weather screen.
This post is different. I've shipped multiple Flutter apps — a full-featured Islamic companion (Huda), an e-commerce platform (Sire), and a social media app (Paloma), among others. Each one taught me something different about state management. Here's what I've settled on — not because it's theoretically optimal, but because it works in practice without making me dread the codebase.
The Mental Model That Actually Helps
Before picking a library, categorize state by its scope:
- Ephemeral state — Lives in a single widget and doesn't need to be shared. Whether a button is pressed, whether a dropdown is open, whether a text field has focus.
- Feature state — Lives inside a screen or feature boundary. Form field values, which tab is active, whether a list is loading.
- App state — Crosses feature boundaries and lives for the whole session or longer. The authenticated user, the user's settings, the shopping cart.
The mistake developers make early on is reaching for a global state solution for ephemeral state. They end up with Riverpod providers for whether a tooltip is visible. That creates noise that makes the real state harder to find and reason about.
setState — Not a Dirty Word
setState has a bad reputation it doesn't fully deserve. For ephemeral state — animations, local UI toggles, single-widget form validation — it's the right tool.
class _ExpandableCardState extends State<ExpandableCard> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: AnimatedContainer(
height: _isExpanded ? 200 : 80,
duration: const Duration(milliseconds: 250),
child: widget.child,
),
);
}
}I use setState in every app I ship. Anyone who tells you to replace this with a Provider is optimizing for consistency at the expense of simplicity.
Where setState fails is when two sibling widgets need the same value. The moment you find yourself drilling callbacks up and data down more than one level, you've outgrown it.
Riverpod — The Backbone for Most Things
For everything that isn't ephemeral local UI state, I use Riverpod. Specifically flutter_riverpod with code generation via riverpod_annotation in any app larger than a demo.
Riverpod's biggest practical advantage isn't type safety (though that's real) — it's composability and testability with almost no boilerplate. Providers can watch other providers. Async providers give you AsyncValue which handles loading and error states in one go. You can override any provider in tests using a ProviderContainer without mocking frameworks.
Here's what a typical async provider looks like in a prayer times feature:
@riverpod
Future<PrayerTimes> prayerTimes(PrayerTimesRef ref) async {
final location = await ref.watch(userLocationProvider.future);
final settings = ref.watch(prayerSettingsProvider);
return PrayerTimesCalculator.calculate(location, settings);
}And in the UI:
class PrayerTimesWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final prayerTimes = ref.watch(prayerTimesProvider);
return prayerTimes.when(
data: (times) => PrayerList(times: times),
loading: () => const CircularProgressIndicator(),
error: (e, _) => ErrorView(message: e.toString()),
);
}
}No manual Future management. No StreamBuilder. The loading and error cases are impossible to forget because AsyncValue.when requires all three.
Where Riverpod Has Friction
Code generation adds a build step — build_runner watch becomes part of your workflow. Debugging reactive chains can be non-obvious when a provider unexpectedly rebuilds. And if you're building something explicitly sequential — a wizard flow, a multi-step checkout — you find yourself fighting Riverpod's reactive model to express something that's fundamentally imperative.
That's where Cubit comes in.
Cubit — When You Need a State Machine
For features with non-trivial state transitions, I reach for Cubit from the bloc package. Not full Bloc — I've never needed the formal event/handler separation in any app I've shipped. Cubit gives you the state machine without the ceremony.
The canonical case is authentication. Auth isn't just bool isLoggedIn. It's a state machine:
Initial → Loading → Authenticated
↘ Unauthenticated
↘ Error
Authenticated → Loading (token refresh) → Authenticated
↘ Unauthenticated (expired)
A Cubit makes this explicit:
class AuthCubit extends Cubit<AuthState> {
AuthCubit(this._authRepository) : super(const AuthState.initial());
final AuthRepository _authRepository;
Future<void> signIn(String email, String password) async {
emit(const AuthState.loading());
try {
final user = await _authRepository.signIn(email, password);
emit(AuthState.authenticated(user));
} on AuthException catch (e) {
emit(AuthState.error(e.message));
}
}
Future<void> signOut() async {
await _authRepository.signOut();
emit(const AuthState.unauthenticated());
}
}In the UI, BlocBuilder gives you the same when-style exhaustive matching:
BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) => state.when(
initial: () => const SplashScreen(),
loading: () => const LoadingScreen(),
authenticated: (user) => HomeScreen(user: user),
unauthenticated: () => const LoginScreen(),
error: (msg) => ErrorScreen(message: msg),
),
)The distinction I keep coming back to: Riverpod is great for data and derived state. Cubit is great for flows and state machines. They're not competing. I use both in the same app.
How I Actually Combine Them
In a typical production app, the layers look like this:
- Repository layer — Plain Dart classes, no framework dependencies
- Riverpod providers — Expose repository instances and async data to the widget tree
- Cubits — Manage UI-level state machines: auth, checkout flows, multi-step forms
- setState — Ephemeral single-widget state
ConsumerWidget (reads Riverpod for data)
└── BlocBuilder (listens to Cubit for UI state)
└── UI calls cubit.method() on user interaction
└── Cubit calls repository (injected via constructor or Riverpod ref)
In practice, most screens only touch one or two of these layers. A read-only list screen might just be a ConsumerWidget with a single Riverpod provider — no Cubit needed. A checkout flow is a Cubit-driven state machine that reads product data from Riverpod providers.
Things I've Learned the Hard Way
Use autoDispose by default. If a provider's state is only needed during a screen's lifecycle, it should be disposed when the screen is gone. Otherwise you accumulate stale state across navigation and get subtle bugs — like search results from a previous user session appearing briefly when a new user opens the app.
@riverpod // code-gen creates autoDispose by default
class SearchNotifier extends _$SearchNotifier {
@override
SearchState build() => const SearchState.empty();
void search(String query) { /* ... */ }
}Sealed classes make impossible states impossible. Using bool isLoading alongside String? error and T? data in the same class leads to states that can't exist (isLoading: true and error != null — which is it?). A sealed class or freezed union forces you to represent only valid combinations.
@freezed
class SearchState with _$SearchState {
const factory SearchState.empty() = _Empty;
const factory SearchState.loading() = _Loading;
const factory SearchState.results(List<Result> items) = _Results;
const factory SearchState.error(String message) = _Error;
}Testing is the real differentiator. I used GetX briefly on a smaller project before committing to Riverpod. GetX is fine for prototypes and small-to-medium apps where you move fast, but its controller lifecycle is tightly coupled to the widget tree — unit testing business logic in isolation is genuinely painful as the codebase grows. With Riverpod, I can test any provider in a ProviderContainer with no widgets involved. That investment pays back every time you revisit a feature months later.
Don't rewrite for consistency. If a screen uses a pattern that works, leave it alone. Chasing a single unified pattern across an entire codebase almost always introduces regressions without meaningful benefit.
Key Takeaways
-
Match the tool to the scope. Ephemeral UI state →
setState. Async data and DI → Riverpod. State machines and flows → Cubit. -
Code generation for Riverpod is worth it. The
build_runnerstep is small overhead for the type safety and boilerplate reduction you get back. -
Cubit, not Bloc. Unless explicit event objects are meaningful for your domain (audit logs, event replay), Cubit is simpler and just as capable.
-
Sealed state is non-negotiable.
isLoading: truealongsidedata != nullis a bug waiting to materialize in production. -
autoDisposeby default. Be intentional about which state actually needs to outlive a screen. -
Don't chase unified patterns. Code that works and is readable is better than code that's consistent for its own sake.
Frequently Asked Questions
Should I use Riverpod or Bloc in my Flutter app?
Both are solid, production-proven choices. Riverpod is excellent for reactive async data and dependency injection. Cubit (from the Bloc package) is excellent for explicit state machines. I use both in the same app for different purposes — they are not mutually exclusive.
Is Provider still worth using?
Provider is effectively superseded by Riverpod, which fixes its main pain points: no BuildContext dependency, compile-time safety, and better support for async state. For new projects, start with Riverpod. If an existing codebase on Provider is stable and the team knows it, there is no urgent reason to migrate.
Is GetX a bad choice?
GetX is a solid choice for prototypes and small-to-medium apps — it's fast to set up and covers routing, DI, and state in one package. The issue is scale: as the app grows, its controller lifecycle is tightly coupled to the widget tree (hard to unit test in isolation), and bundling routing, DI, and state into one package makes each concern harder to reason about independently. For anything production-grade or long-lived, Riverpod is the better investment.
How do I handle global app state like authentication?
Model auth as a sealed class state machine, not as scattered booleans. A Cubit<AuthState> placed high in the widget tree (or provided via Riverpod) works cleanly. The key is exhaustive state representation — initial, loading, authenticated, unauthenticated, and error — rather than combining nullable fields.
When should I use Redux or MobX in Flutter?
Almost never for new projects. Redux brings heavy boilerplate and an event-sourcing mindset that doesn't map naturally onto Flutter's widget model. MobX is fine but adds a code generation step without adding much over Riverpod. Start with Riverpod and Cubit — add complexity only when you have a concrete reason.