State management is where Flutter projects either stay sane or rot. The framework ships with setState, which is fine for a single widget, but as soon as state needs to be shared across screens you need something better. I have shipped apps on Provider, Riverpod, and Bloc, and each one earns its place in different situations. Here is how I choose.
Why setState stops being enough
setState rebuilds the widget it lives in. That works until two screens need the same data, or until a deep child needs a value held near the root. You end up passing callbacks and values down through constructors, layer after layer, and the term for that misery is prop drilling. Every state library exists to solve this same problem of getting data to where it is needed without threading it through everything in between.
Provider, the gentle start
Provider is the official recommendation for a long time and still a reasonable default. It sits on top of inherited widgets and gives you a clean way to expose a value to the tree below it. A ChangeNotifier holds your state and calls notifyListeners when something changes, and widgets that listen rebuild. It is simple to reason about and easy to teach a new team member.
import 'package:flutter/material.dart';
class CartModel extends ChangeNotifier {
final List<String> _items = [];
List<String> get items => _items;
void add(String item) {
_items.add(item);
notifyListeners();
}
}
The weakness of Provider shows up at scale. It is tied to the widget tree, so testing logic in isolation takes effort, and it is easy to rebuild more of the tree than you intended. For small and medium apps I have no complaints.
Riverpod, what I reach for now
Riverpod is from the same author as Provider and fixes most of its pain. State lives outside the widget tree, so you can read it without a BuildContext, test it without pumping widgets, and catch mistakes at compile time instead of runtime. Providers are declared as top level variables and you watch them where you need them.
import 'package:flutter_riverpod/flutter_riverpod.dart';
final counterProvider = StateProvider<int>((ref) => 0);
class CounterText extends ConsumerWidget {
const CounterText({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('Count is ' + count.toString());
}
}
What I like most is how it handles async. A FutureProvider gives you loading, error, and data states without writing the boilerplate yourself, which connects nicely to the network work I describe in getting started with Flutter. For most new projects this is my default choice.
Bloc, when discipline matters
Bloc is heavier and more opinionated. You model your app as events going in and states coming out, and the strict separation makes large teams predictable. Every change to state is an explicit event with a clear handler, which makes the app easy to trace and to test. The cost is ceremony. Simple features need a lot of code.
- Provider: low ceremony, tied to the tree, great for smaller apps
- Riverpod: testable, compile safe, strong async support, my default
- Bloc: verbose but predictable, shines on large teams and complex flows
How I actually decide
I match the tool to the team and the app. A solo project or a prototype gets Provider or Riverpod because I want to move fast. A large app with many contributors and complex business rules gets Bloc because the structure pays for itself in fewer surprises. The wrong move is picking the heaviest tool for the smallest job because a blog post told you it was best practice.
Keep state out of the build method
Whatever you choose, one rule holds across all of them. Never create or mutate state inside a build method, because build can run many times per second and you will create garbage or trigger loops. Keep state in the right place, listen to it, and let the framework rebuild. Doing this well also keeps your app smooth, which ties into Flutter performance optimization. Get state right and most other problems become smaller.