State Management
Prerequisites: Stateful widgets and user input
When several widgets — or several screens — need the same data (a shopping cart, the logged-in user, settings), passing it through every constructor gets painful. State management keeps shared state in one place and rebuilds just the widgets that depend on it.
ChangeNotifier — built into Flutter
import 'package:flutter/foundation.dart';
class CartModel extends ChangeNotifier {
final List<String> _items = [];
List<String> get items => List.unmodifiable(_items);
int get total => _items.length * 5;
void add(String item) {
_items.add(item);
notifyListeners();
}
void clear() {
_items.clear();
notifyListeners();
}
}
- The model holds the data and the logic, with no UI code — so it's easy to unit test.
notifyListeners()tells any listening widgets to rebuild.- Exposing an unmodifiable list stops widgets from changing the cart
without going through
addorclear.
Connecting it to widgets
ListenableBuilder rebuilds when the model notifies:
class CartBadge extends StatelessWidget {
const CartBadge({super.key, required this.cart});
final CartModel cart;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: cart,
builder: (context, _) => Text('${cart.items.length} items · \$${cart.total}'),
);
}
}
To make a model available anywhere below a point in the widget tree —
without passing it through every constructor — use a package such as
provider (context.watch<CartModel>()), which is built on the same
idea.
Popular state management packages
- Provider — simple and widely used; recommended for learning.
- Riverpod — a successor to Provider from the same author: compile-safe, testable, and not tied to the widget tree.
- Bloc / Cubit — models state changes as streams of events and states; popular in larger teams for its strict structure.
All share the same core ideas: state lives outside widgets, widgets watch it and rebuild, and user actions call methods that change it.
Modeling state with sealed classes
Sealed classes (Level 2) are an excellent way to describe screen state:
sealed class ProfileState {}
class ProfileLoading extends ProfileState {}
class ProfileLoaded extends ProfileState {
ProfileLoaded(this.name);
final String name;
}
class ProfileError extends ProfileState {
ProfileError(this.message);
final String message;
}
Widget buildProfile(ProfileState state) => switch (state) {
ProfileLoading() => const CircularProgressIndicator(),
ProfileLoaded(:final name) => Text('Hello, $name'),
ProfileError(:final message) => Text('Error: $message'),
};
The switch is exhaustive, so a new state can't be forgotten in the UI.
Common mistakes
- Forgetting
notifyListeners(), so the UI never updates. - Putting everything in global state. Local UI state (a text field, a
toggle) belongs in the widget with
setState.