Flutter state management becomes confusing when the conversation starts with package names.
The better starting point is ownership: what can change, who is allowed to change it, and which parts of the UI need to know? Once those answers are clear, the mechanism is usually straightforward.
This guide begins with setState, identifies the point where local state becomes application state, and compares Provider, Riverpod, and BLoC without pretending one tool is universally best.
State is data that changes over time#
In a Flutter app, state includes more than server responses:
- the selected tab;
- text entered into a form;
- whether a request is in progress;
- authenticated user information;
- cached products;
- validation failures and retry state.
Some state belongs to one widget and disappears when that widget leaves the tree. Other state is shared across routes, survives navigation, coordinates asynchronous work, or represents a business workflow.
That distinction matters more than the library.
Start with local state#
setState is Flutter's built-in way to tell a StatefulWidget that its local data changed and it should build again.
class QuantityPicker extends StatefulWidget {
const QuantityPicker({super.key});
@override
State<QuantityPicker> createState() => _QuantityPickerState();
}
class _QuantityPickerState extends State<QuantityPicker> {
int _quantity = 1;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: _quantity == 1
? null
: () => setState(() => _quantity--),
icon: const Icon(Icons.remove),
),
Text('$_quantity'),
IconButton(
onPressed: () => setState(() => _quantity++),
icon: const Icon(Icons.add),
),
],
);
}
}
This is not a primitive solution waiting to be replaced. The state is small, synchronous, and owned by one widget. Moving it into a global container would make the feature harder to follow.
Use setState when:
- one widget or a small subtree owns the value;
- the state has the same lifetime as that widget;
- transitions are simple;
- the logic does not need an isolated unit test.
Recognize when state has outgrown the widget#
A product screen might begin by fetching data directly:
class ProductListScreen extends StatefulWidget {
const ProductListScreen({super.key});
@override
State<ProductListScreen> createState() => _ProductListScreenState();
}
class _ProductListScreenState extends State<ProductListScreen> {
List<Product> _products = const [];
bool _loading = true;
Object? _error;
// Fetching and rendering logic now share this class.
}
The problem is not that setState rebuilds widgets. Rebuilding is how Flutter works. The pressure comes from mixed responsibilities and increasingly complex transitions.
Move state into a dedicated owner when:
- multiple widgets need the same source of truth;
- asynchronous work needs loading, success, empty, and error states;
- business rules are mixed into event handlers;
- state must survive a widget's lifecycle;
- transitions deserve focused tests;
- dependencies such as repositories need clear ownership and disposal.
Model states, not combinations of flags#
Several booleans can describe impossible situations: loading and failed, or empty and loaded at the same time. A sealed hierarchy makes valid states explicit.
sealed class ProductState {
const ProductState();
}
class ProductLoading extends ProductState {
const ProductLoading();
}
class ProductReady extends ProductState {
const ProductReady(this.products);
final List<Product> products;
}
class ProductFailure extends ProductState {
const ProductFailure(this.message);
final String message;
}
The UI can handle the states exhaustively:
Widget buildProducts(ProductState state) {
return switch (state) {
ProductLoading() => const CircularProgressIndicator(),
ProductReady(:final products) when products.isEmpty =>
const Text('No products found'),
ProductReady(:final products) => ProductList(products: products),
ProductFailure(:final message) => Text(message),
};
}
This improvement is independent of Provider, Riverpod, or BLoC. Good state modeling travels between tools.
Separate the workflow from rendering#
A small view model can own the asynchronous transition:
class ProductViewModel extends ChangeNotifier {
ProductViewModel(this._repository);
final ProductRepository _repository;
ProductState state = const ProductLoading();
Future<void> load() async {
state = const ProductLoading();
notifyListeners();
try {
final products = await _repository.fetchProducts();
state = ProductReady(products);
} catch (error) {
state = const ProductFailure('Could not load products');
}
notifyListeners();
}
}
The state owner does not render widgets. The widget does not fetch or parse data. This separation is where most of the maintainability benefit comes from.
Provider: simple access to tree-scoped objects#
Provider builds on Flutter's inherited-widget mechanism and is well suited to exposing objects down a subtree. A common pattern is a ChangeNotifier view model plus ChangeNotifierProvider.
Provider is a good fit when:
- the team understands Flutter's widget lifecycle;
- state naturally belongs to a subtree;
ChangeNotifieror immutable value objects are sufficient;- you want a relatively small conceptual jump from Flutter's built-in APIs.
Its main risk is allowing a notifier to grow into a feature-wide “god object.” Keep state owners focused, expose intentional operations, and avoid putting unrelated state into one provider merely because it is convenient to access.
Riverpod: explicit dependencies and flexible lifecycles#
Riverpod moves provider definitions outside BuildContext and makes dependencies between providers explicit. It offers families, automatic disposal, overrides for tests, and APIs for synchronous and asynchronous state.
Riverpod is a good fit when:
- features have several stateful dependencies;
- asynchronous state and caching are common;
- test overrides are valuable;
- state lifecycles do not map neatly to one widget subtree;
- the team wants compile-time-friendly dependency composition.
Riverpod gives a team many ways to solve a problem. Establish conventions for provider scope, naming, generated code, and where business rules live so that flexibility does not become inconsistency.
BLoC and Cubit: explicit transitions and team conventions#
BLoC organizes state around inputs and outputs. Full BLoC uses events that produce states; Cubit exposes methods that emit states with less ceremony.
class ProductCubit extends Cubit<ProductState> {
ProductCubit(this._repository) : super(const ProductLoading());
final ProductRepository _repository;
Future<void> load() async {
emit(const ProductLoading());
try {
emit(ProductReady(await _repository.fetchProducts()));
} catch (error) {
emit(const ProductFailure('Could not load products'));
}
}
}
BLoC or Cubit is a good fit when:
- explicit, observable transitions matter;
- workflows have many events and states;
- a larger team benefits from one strong convention;
- event transformations, logging, or replayable debugging are useful;
- boilerplate is an acceptable trade for predictability.
Use Cubit when methods describe the interactions clearly. Introduce events when event identity, transformation, or decoupling provides real value.
Compare the decision, not the popularity#
| Approach | Best starting point | Main strength | Watch for |
|---|---|---|---|
setState | Local, short-lived UI state | Minimal indirection | Mixing data access and business workflows into widgets |
| Provider | Tree-scoped objects and modest shared state | Familiar Flutter lifecycle and simple composition | Large mutable notifiers and broad rebuilds |
| Riverpod | Dependency-rich and asynchronous features | Explicit composition, disposal, overrides, caching patterns | Too many provider styles without team conventions |
| BLoC/Cubit | Complex workflows or convention-heavy teams | Predictable transitions and strong separation | Ceremony for simple interactions |
Flutter's own state-management overview lists multiple valid approaches because application constraints differ. Package choice should follow the problem and the team, not a universal ranking.
Rebuilds are not automatically a performance problem#
Flutter is designed to rebuild widgets. Optimize when measurement shows expensive work.
Useful habits include:
- keep expensive computation outside
build; - watch the smallest state slice a widget needs;
- split large widgets at meaningful responsibility boundaries;
- use
constwhere values are actually constant; - profile with DevTools before introducing caching or selectors everywhere.
A state-management package cannot compensate for slow work performed during every build, and a rebuild counter alone does not prove a user-visible problem.
Test behavior through the state owner#
When the workflow lives outside the widget, a test can verify transitions without pumping a screen:
test('load publishes products returned by the repository', () async {
final repository = FakeProductRepository(
products: const [Product(id: '1', name: 'Keyboard')],
);
final viewModel = ProductViewModel(repository);
await viewModel.load();
expect(viewModel.state, isA<ProductReady>());
});
Then use widget tests for presentation: loading indicators, empty messages, errors, retry actions, and product rows.
A practical selection process#
Before adopting a package, answer these questions:
- Is the state local, shared, or application-wide?
- Who owns its lifecycle?
- Are transitions simple methods or meaningful events?
- Does the state coordinate multiple repositories or workflows?
- What conventions does the team already understand?
- How will the behavior be tested and debugged?
Try the smallest approach that answers those questions cleanly. Build one representative feature before setting a project-wide standard.
Structure should grow with responsibility#
Use setState confidently for local UI state. Extract a state owner when workflows, sharing, or testing demand it. Choose Provider for straightforward tree-scoped composition, Riverpod for richer dependency and lifecycle needs, and BLoC or Cubit when explicit transitions and strong conventions help the team.
The goal is not to eliminate rebuilds or adopt the most powerful library. It is to make state ownership obvious and changes predictable.