Module 12: State Management with Provider
Teach: Why lifting state through constructors breaks down in real apps, and how Provider solves this with ChangeNotifier and a widget-tree-based pub/sub system. See: A shopping cart app where the cart model is shared across a product catalog and cart screen without prop drilling. Feel: The relief of not passing state through 5 layers of constructors.
The Problem: Constructor Spaghetti
Teach: Why passing state through constructors breaks down as apps grow, and what prop drilling looks like. See: The progression from setState to lifting state to constructor spaghetti, and why Provider is the next step. Feel: The pain of prop drilling viscerally enough to appreciate why state management exists.
You have been passing data between widgets through constructors. Parent builds child, passes data down. Child needs to tell parent something? Callback function, passed down. It works. Until it doesn't. Imagine a shopping cart. The cart icon in the AppBar needs the item count. The product list needs to add items. The cart screen needs to display items and update quantities. The checkout screen needs the total. That cart data has to flow through every widget between those screens, even widgets that do not care about the cart at all. This is called prop drilling, and it is the problem that state management solves.
Here is the progression every Flutter developer goes through:
- Single widget state --
setStateinside aStatefulWidget. Fine for one screen. - Lifting state up -- Move state to a parent widget, pass down through constructors. Works for 2-3 levels.
- Constructor spaghetti -- State needs to reach widgets 5 levels deep, or siblings need to share state. Constructors everywhere. Callbacks everywhere. Pain everywhere.
- State management -- A system that lets any widget access shared state directly, without drilling through the tree.
The provider package is Flutter's recommended solution for step 4. It is simple, well-documented, and built on Flutter's own InheritedWidget mechanism.
Setup
Add Provider to your project:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
provider: ^6.1.1
Then run flutter pub get.
ChangeNotifier: Your State Model
Teach: How to create a ChangeNotifier class that holds state and broadcasts changes with notifyListeners. See: A CartModel with private state, public getters, and mutating methods that notify the UI. Feel: That building a state model is just writing a normal Dart class with one extra method call.
A ChangeNotifier is just a Dart class that can say "hey, I changed!" to anyone listening. You extend it, put your state variables inside, and call notifyListeners whenever something changes. That is it. No annotations, no code generation, no boilerplate. Just a class that yells when its data changes.
Think of ChangeNotifier like a radio station. It broadcasts whenever something changes. Any widget that is tuned in will hear the broadcast and rebuild.
class CartModel extends ChangeNotifier {
final List<CartItem> _items = [];
// Public read-only access
List<CartItem> get items => List.unmodifiable(_items);
int get totalItems => _items.fold(0, (sum, item) => sum + item.quantity);
double get totalPrice =>
_items.fold(0, (sum, item) => sum + item.price * item.quantity);
void addItem(CartItem item) {
final existing = _items.where((i) => i.id == item.id);
if (existing.isNotEmpty) {
existing.first.quantity++;
} else {
_items.add(item);
}
notifyListeners(); // Broadcast: "I changed!"
}
void removeItem(int id) {
_items.removeWhere((item) => item.id == id);
notifyListeners();
}
void clearCart() {
_items.clear();
notifyListeners();
}
}
The pattern is always the same:
1. Private state variables (_items)
2. Public getters for reading state
3. Methods that modify state and call notifyListeners()
Every method that changes state MUST call notifyListeners(). If you forget, widgets will not rebuild and the UI will be stale.
ChangeNotifierProvider: Plugging In
Teach: How ChangeNotifierProvider makes a ChangeNotifier available to all descendant widgets in the tree. See: The provider wrapping the entire app, making CartModel accessible everywhere. Feel: That wiring up Provider is surprisingly simple -- just wrap and go.
A ChangeNotifierProvider does two things: it creates an instance of your model, and it makes that instance available to every widget below it in the tree.
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CartModel(),
child: MaterialApp(
home: ProductCatalog(),
),
),
);
}
That is the entire setup. CartModel is now available to ProductCatalog, its children, their children, and so on. No constructors needed.
Where to place the Provider
Place it above all the widgets that need access. For app-wide state like a shopping cart or user session, wrap the entire MaterialApp. For screen-specific state, wrap just that screen.
The placement rule matters more than it sounds. Providers follow the widget tree — a widget can only read a provider that's above it. Put your provider too low and the widgets that need it won't find it. Put it too high and you're rebuilding parts of the tree that don't care. For user sessions, themes, and shopping carts, wrap the whole MaterialApp — these belong everywhere. For screen-local state like "the currently expanded accordion section," wrap just that screen. The "where does this state need to be visible" question decides placement.
Reading State: Three Ways
Teach: The three Provider access patterns -- Consumer, context.watch, and context.read -- and when to use each. See: Side-by-side examples showing Consumer for surgical rebuilds, watch for display, and read for actions. Feel: Clear about which access method to reach for in any situation.
Provider gives you three ways to access your model. Each has a specific use case.
Consumer -- The Surgical Rebuilder
Consumer rebuilds only its builder function when the model changes. Everything outside the Consumer stays untouched.
Consumer<CartModel>(
builder: (context, cart, child) {
return Text('Items in cart: ${cart.totalItems}');
},
)
Use Consumer when you want to minimize rebuilds. The child parameter lets you pass a widget that should NOT rebuild:
Consumer<CartModel>(
builder: (context, cart, child) {
return Row(
children: [
child!, // This Icon never rebuilds
Text('${cart.totalItems}'),
],
);
},
child: Icon(Icons.shopping_cart), // Built once, reused
)
context.watch -- The Easy Reader
@override
Widget build(BuildContext context) {
final cart = context.watch<CartModel>();
return Text('Total: \$${cart.totalPrice.toStringAsFixed(2)}');
}
context.watch is simpler than Consumer but rebuilds the entire widget when the model changes. Use it when the whole widget depends on the model anyway.
context.read -- The Fire-and-Forget
ElevatedButton(
onPressed: () {
context.read<CartModel>().addItem(
CartItem(id: 1, name: 'Widget', price: 9.99, quantity: 1),
);
},
child: Text('Add to Cart'),
)
context.read gets the model without subscribing to changes. Use it in event handlers and callbacks -- places where you want to do something to the model, not display its data.
Here is the rule of thumb. Are you displaying data? Use watch or Consumer. Are you calling a method in response to a user action? Use read. If you use watch in a callback, the widget will rebuild unnecessarily every time the model changes, even if the callback never fires. If you use read in a build method, the widget will not update when the model changes. Get them backwards and weird things happen.
The Legacy Syntax
You might see older code using Provider.of:
// Same as context.watch
final cart = Provider.of<CartModel>(context);
// Same as context.read
final cart = Provider.of<CartModel>(context, listen: false);
Both work, but context.watch and context.read are cleaner and preferred in modern code.
MultiProvider: Multiple Models
Teach: How to provide multiple independent ChangeNotifier models using MultiProvider. See: A MultiProvider wrapping the app with CartModel, UserModel, and SettingsModel all available independently. Feel: That scaling to multiple models is just adding entries to a list -- no architectural overhaul needed.
Real apps have more than one piece of shared state. A user model, a cart model, a settings model. MultiProvider lets you provide them all:
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CartModel()),
ChangeNotifierProvider(create: (_) => UserModel()),
ChangeNotifierProvider(create: (_) => SettingsModel()),
],
child: MaterialApp(
home: HomeScreen(),
),
),
);
}
Each widget can then access whichever model it needs:
final cart = context.watch<CartModel>();
final user = context.watch<UserModel>();
They are completely independent. Changing the cart does not rebuild widgets that only watch the user model.
MultiProvider is how you scale this pattern to real apps. In production, you almost always have several independent models — user session, cart, settings, maybe a notifications model. MultiProvider lets you register them all at the root without nesting ChangeNotifierProvider inside ChangeNotifierProvider inside ChangeNotifierProvider. And the independence is key. A widget that only watches UserModel does not rebuild when the cart changes. Provider is smart enough to track which widgets depend on which models and only rebuild the ones affected by a change.
There Are No Dumb Questions
Teach: Answers to common Provider questions about setState boundaries, disposal, Selector, and alternative packages. See: Clear guidelines for when to use setState vs Provider, and how Consumer rebuilds work. Feel: That the common pitfalls and edge cases are well-mapped and avoidable.
Q: When should I use setState vs Provider?
A: Use setState for state that belongs to a single widget -- a text field's content, whether an accordion is expanded, an animation value. Use Provider for state that multiple widgets need to share -- user authentication, a shopping cart, app settings.
Q: Can I have multiple Providers of the same type?
A: You can, but the closest one in the tree wins. This is rarely what you want. If you need two carts (why?), make them different types or use a wrapper class.
Q: What happens if I forget to call notifyListeners?
A: Your state changes silently. The model updates internally, but no widgets rebuild. The UI looks stale. This is the number one Provider debugging issue.
Q: Does Consumer rebuild for EVERY change to the model?
A: Yes. If your model has 10 properties and you change one, every Consumer for that model rebuilds. For fine-grained control, you can use Selector to watch specific properties, or split your model into smaller, focused models.
Q: Is Provider the "best" state management solution?
A: There is no single best. Provider is the officially recommended starting point. Riverpod (by the same author) is more powerful and type-safe. Bloc is popular for large enterprise apps. For learning Flutter, Provider is the right choice -- it teaches the core concepts without extra complexity.
Q: Do I need to dispose of a ChangeNotifier?
A: ChangeNotifierProvider handles disposal automatically. When the provider is removed from the tree, it calls dispose() on the model. If you create a ChangeNotifier manually (not through a provider), you are responsible for disposing it to prevent memory leaks.
Q: Can I use Provider with StatefulWidgets?
A: Yes, and you often will. A StatefulWidget can use context.watch in its build method and context.read in its event handlers, just like a StatelessWidget. The local state (animations, TextEditingControllers) lives in the StatefulWidget, while shared state lives in Provider.
The setState-versus-Provider decision confuses people more than it should. Here's the one-line rule: if only THIS widget cares about the value, setState. If multiple widgets care, Provider. A text field's current text? Only the field cares until the user submits — setState is fine. The current user's authentication status? Half the app needs that — Provider. When you're tempted to pass a callback three widgets down so a child can tell a grandparent "the user logged out," you've reached Provider territory. Lift that state up and out.
A Real-World Example: The Cart Badge
Teach: How Consumer enables surgical widget rebuilds in a real UI scenario. See: A cart icon badge in the AppBar that updates its count without rebuilding the entire AppBar. Feel: That Provider's efficiency is practical, not theoretical -- it matters in real UI components.
Here is a concrete example of why Provider is so useful. You want a cart icon in the AppBar that shows the number of items. Without Provider, the cart count has to be passed through every widget between the cart model and the AppBar. With Provider, the badge just asks for it directly:
AppBar(
title: Text('Products'),
actions: [
Stack(
children: [
IconButton(
icon: Icon(Icons.shopping_cart),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => CartScreen()),
);
},
),
Positioned(
right: 4,
top: 4,
child: Consumer<CartModel>(
builder: (context, cart, child) {
if (cart.totalItems == 0) return SizedBox.shrink();
return CircleAvatar(
radius: 10,
backgroundColor: Colors.red,
child: Text(
'${cart.totalItems}',
style: TextStyle(fontSize: 12, color: Colors.white),
),
);
},
),
),
],
),
],
)
The Consumer wraps only the badge, so when the cart changes, only that tiny CircleAvatar rebuilds. The rest of the AppBar, the IconButton, the Text -- all untouched. This is surgical rebuilding, and it is what makes Provider efficient even in complex UIs.
The cart badge is the classic "why Provider" example because it shows the pattern at its most elegant. Without Provider, getting the count to the badge means passing it from CartModel through MaterialApp through Scaffold through AppBar to the badge itself. Five layers of widgets need to know about the cart, even though only one of them cares. With Provider, the badge reaches up and grabs the count directly. Everything between doesn't know the cart exists. That's not just cleaner code — it's more maintainable. Add a new screen that needs the count, and it can grab it the same way.
Sharpen Your Pencil: Shopping Cart App
Teach: How to build a full shopping cart app with Provider managing shared state across multiple screens. See: A product catalog, cart screen, and cart badge all reading from and writing to one CartModel. Feel: The satisfaction of shared state working seamlessly without a single constructor chain.
Build a complete shopping cart application using Provider for state management.
CartItem and CartModel (cart_model.dart)
- Define a
CartItemclass withid(int),name(String),price(double), andquantity(int). - Create
CartModel extends ChangeNotifierwith: List<CartItem> get items(unmodifiable)int get totalItems(sum of quantities)double get totalPrice(sum of price * quantity)addItem(CartItem)-- increment quantity if exists, otherwise addremoveItem(int id)-- remove by idupdateQuantity(int id, int newQuantity)-- update or remove if zeroclearCart()-- empty the list- All mutating methods call
notifyListeners()
ProductCatalog (product_catalog.dart)
- A
StatelessWidgetwith 8+ hardcoded products. ListView.builderwithCard>ListTilefor each product.- Trailing
IconButton(add_shopping_cart) that callscontext.read<CartModel>().addItem(...). - Show a
SnackBaron add. - AppBar with a cart icon badge using
Consumer<CartModel>showingtotalItems. Tap navigates toCartScreen.
CartScreen (cart_screen.dart)
- A
StatelessWidgetusingConsumer<CartModel>. - Empty state: centered message with icon.
- Non-empty:
ListView.builderwith each item showing name, price x quantity, and controls: - Decrement button (calls
updateQuantitywith quantity - 1) - Quantity text
- Increment button
- Delete button (calls
removeItem) - Summary section at bottom: total items, total price (bold, large), and a red "Clear Cart" button.
App Entry Point (shopping_app.dart)
main()wrapsMaterialAppinChangeNotifierProvider<CartModel>.- Home is
ProductCatalog. - Navigation to
CartScreenviaNavigator.push.
This is the exercise that ties everything together — classes, lists, navigation, Provider. The CartModel holds the state. The catalog adds to it. The cart screen reads and mutates it. The AppBar badge watches it. All three screens interact with the same model without any constructor plumbing between them. When you finish this app and hit the checkout button, take a second to appreciate what just happened: state flowed through four different widgets in two different screens, and not a single line of prop drilling was needed. That's what state management is supposed to feel like.
📝 Module Quiz
Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.
The State of Things
Teach: A recap of Provider's core pattern -- define, provide, consume -- and its place in the Flutter ecosystem. See: The complete state management story from the problem through the solution. Feel: That state management is a solved problem, and Provider makes it approachable.
Provider is one of those tools that makes you wonder why you ever did it the hard way. You define your model, wrap it in a provider, and any widget anywhere in the tree can access it. No constructor chains, no callback pyramids. The model notifies, the widgets rebuild. State management does not have to be complicated, and Provider proves it.
Where this fits: State management is the bridge between small demos and real applications. With navigation, theming, and Provider, you can now build multi-screen apps with shared state and consistent styling. Next, you will learn to fetch data from the internet and display it in your apps.