State Management in Flutter: Picking the Right Tool for the Job
setState, Provider, Riverpod, Bloc - Flutter's state management options aren't competing for the same job. A guide to matching the tool to the actual problem.

Ask five Flutter developers which state management solution to use and you'll get five confident, mutually exclusive answers. The honest answer is that "it depends", but that's only useful advice if you know what it depends on. Here's a more concrete way to decide.
The widget tree is the whole problem
Every state management approach exists to solve the same underlying issue: how does a piece of data change in one place and correctly update every widget that depends on it, without rebuilding everything or wiring props through ten layers of widgets that don't care about the data themselves.
A simplified Flutter widget tree showing state shared across HomeScreen, ProductList, CartBadge, and SettingsScreen through a ProviderScope
In the diagram above, CartBadge and ProductList both need to know about the cart, but they live in different branches of the tree. That's the exact shape of problem every state management library is trying to solve.
setState: correct for genuinely local state
setState isn't a beginner tool to be abandoned as soon as possible, it's the right answer when state doesn't need to leave the widget that owns it.
class ExpandableCard extends StatefulWidget {
const ExpandableCard({super.key});
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Card(child: Text(_expanded ? "Less" : "More")),
);
}
}If no other widget in the app needs to know whether this card is expanded, reaching for a global state solution here adds indirection with no payoff.
Provider and Riverpod: shared state without prop drilling
Once state needs to be read by widgets in different branches of the tree, like the cart badge and the product list above - passing it down through constructors gets unmanageable fast. Provider (and its successor, Riverpod) solve this by letting any descendant widget read state without it being threaded through every intermediate widget.
final cartProvider = StateNotifierProvider<CartNotifier, Cart>(
(ref) => CartNotifier(),
);
class CartBadge extends ConsumerWidget {
const CartBadge({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemCount = ref.watch(cartProvider).items.length;
return Badge(label: Text('$itemCount'));
}
}Riverpod's main advantage over Provider is compile-time safety, it doesn't depend on BuildContext, which makes state accessible from outside the widget tree (in tests, or in business logic) without workarounds.
Bloc: when the transitions matter as much as the state
Bloc earns its extra ceremony in apps where the sequence of state changes is itself important, think multi-step checkout flows, authentication with several intermediate states, or anything where you want an explicit, testable record of "this event caused this transition."
sealed class AuthEvent {}
class LoginRequested extends AuthEvent {}
sealed class AuthState {}
class AuthInitial extends AuthState {} // Added this line to fix the compilation error
class AuthLoading extends AuthState {}
class AuthSuccess extends AuthState {}
class AuthFailure extends AuthState {}
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(AuthInitial()) {
on<LoginRequested>((event, emit) async {
emit(AuthLoading());
try {
await authRepository.login();
emit(AuthSuccess());
} catch (_) {
emit(AuthFailure());
}
});
}
}That explicitness is valuable in complex flows and unnecessary overhead in simple ones, using Bloc for a dark-mode toggle is solving a problem you don't have.
Bloc/Cubit: simplicity and speed for direct use cases
Pure Bloc isn't ideal for simple cases like the one mentioned; however, the package comes with a bonus Cubit which covers well most use cases.
The Cubit eliminates the need for structured events. Instead of dispatching an event and waiting for a handler to process it, you invoke a function directly to emit a new state.
It is ideal for simple or reactive states, such as toggling a dark-mode theme, opening/closing a drawer, or fetching a straightforward list of data where the intermediate transition history does not matter.
sealed class ThemeState {}
class LightTheme extends ThemeState {}
class DarkTheme extends ThemeState {}
class ThemeCubit extends Cubit<ThemeState> {
ThemeCubit() : super(LightTheme());
void toggleTheme() {
if (state is LightTheme) {
emit(DarkTheme());
} else {
emit(LightTheme());
}
}
}Cubit is ideal for solving simpler problems where using Bloc would be overkill.
A simple heuristic
- State used by exactly one widget →
setState. - State shared across a few widgets, no complex transition logic → Provider or Riverpod.
- State with meaningful transitions, side effects, and a need for strict testability → Bloc/Cubit - Use Cubit for direct actions like toggling a theme or liking a post; use Bloc when the event log and stream transformations matter.
Pick based on the shape of the problem in front of you, not the library that was trending when you started the project.