State Management: Provider
Provider is, by a wide margin, one of the most widely used state management
packages in the Flutter ecosystem. It doesn't invent a brand-new mechanism — under the hood it's
built directly on top of Flutter's own InheritedWidget, the same low-level tool
Flutter itself uses to pass things like the current Theme down the widget tree. What
Provider adds is a much friendlier API on top: instead of writing your own
InheritedWidget subclass and a static .of(context) method by hand, you get
ChangeNotifierProvider, context.watch, context.read, and
Consumer — all ready to use out of the box. This lesson solves the exact problem left
open at the end of State Management: setState:
passing data down through many layers of widgets by hand, usually called "prop
drilling."
The Core Idea: ChangeNotifier
Everything in Provider starts with a plain Dart class that extends ChangeNotifier.
This class holds your actual application state — it has nothing Flutter-specific about it at all,
it's really just a regular object with fields and methods. The one addition is a call to
notifyListeners() every time the state changes in a way the UI should react to. That
single method call is what tells every widget listening to this object: "something changed, rebuild
yourself."
class CartModel extends ChangeNotifier {
final List<String> _items = [];
List<String> get items => _items;
int get itemCount => _items.length;
double get totalPrice => _items.length * 9.99;
void addItem(String item) {
_items.add(item);
// Tell every widget listening to this CartModel to rebuild
notifyListeners();
}
void removeItem(String item) {
_items.remove(item);
notifyListeners();
}
}
_items is private (the leading underscore) and only exposed through a getter. Widgets never mutate CartModel's list directly — they always call a method like addItem(), which is what guarantees notifyListeners() actually gets called.Registering It: ChangeNotifierProvider
A ChangeNotifier on its own is just a Dart object sitting in memory — nothing connects
it to the widget tree yet. ChangeNotifierProvider is the widget that does that
connection: it creates one instance of your model and makes it available to every widget below it
in the tree. It's most commonly placed once, near the very top of the app, wrapping
MaterialApp itself.
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CartModel(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: CartScreen(),
);
}
}
If only one screen and its children need the model, you can instead wrap just that subtree — the
model is created once, and every descendant widget of the provider can reach it, no matter how
deeply nested. If you have several independent models, MultiProvider lets you register
more than one ChangeNotifierProvider at once without deeply nesting them.
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CartModel()),
ChangeNotifierProvider(create: (_) => UserModel()),
],
child: const MyApp(),
)
Reading State: context.watch vs context.read
Once CartModel is registered, any descendant widget can grab it two different ways —
and picking the right one matters.
| Method | What it does | When to use it |
|---|---|---|
context.watch<CartModel>() | Subscribes this widget to the model — whenever notifyListeners() fires, this widget's build() runs again | Inside build(), when the widget's UI needs to reflect the current state |
context.read<CartModel>() | Grabs the model once, right now, without subscribing to future changes | Inside callbacks like onPressed — you're triggering an action, not building UI, so there's nothing to rebuild |
class CartScreen extends StatelessWidget {
const CartScreen({super.key});
@override
Widget build(BuildContext context) {
// watch() subscribes this whole build() to CartModel changes
final cart = context.watch<CartModel>();
return Scaffold(
appBar: AppBar(title: Text('Cart (${cart.itemCount})')),
body: ListView.builder(
itemCount: cart.items.length,
itemBuilder: (context, index) => ListTile(title: Text(cart.items[index])),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// read() here — we're calling a method, not building UI
context.read<CartModel>().addItem('New item');
},
child: const Icon(Icons.add),
),
);
}
}
context.watch() inside onPressed, it wouldn't cause an error — but it would be pointless, since onPressed only runs once per tap and isn't part of the widget's rebuild cycle. Worse, calling watch() outside of build() at all will actually throw a runtime error, since it depends on Flutter's build mechanism to register the subscription. read() works anywhere.Rebuilding Less: The Consumer Widget
context.watch() rebuilds the entire build() method it's called
in. For a small widget that's fine, but inside a large screen where only one small piece — say, a
cart item counter badge in the app bar — actually needs to react to CartModel, rebuilding
everything is wasteful. Consumer<CartModel> solves this by isolating the rebuild
to just its own builder callback, leaving the rest of the surrounding widget tree
untouched.
Scaffold(
appBar: AppBar(
title: const Text('My Store'),
actions: [
// Only this Consumer rebuilds when CartModel changes —
// the AppBar title and the rest of the Scaffold do not.
Consumer<CartModel>(
builder: (context, cart, child) {
return Badge(
label: Text('${cart.itemCount}'),
child: child,
);
},
// This Icon never changes, so it's passed in as `child`
// instead of being rebuilt every single time.
child: const Icon(Icons.shopping_cart),
),
],
),
body: const ProductList(),
)
Notice the optional child parameter on Consumer: any part of the subtree
that never depends on CartModel can be built once, outside the builder
callback, and passed in — Provider is smart enough to reuse that same widget instance across
rebuilds instead of recreating it every time.
Solving Prop Drilling
Think back to the problem raised at the end of the setState lesson: if a piece of state lives in one widget but a distant descendant three or four levels down needs it, you'd normally have to pass it as a constructor parameter through every widget in between — even the ones that don't care about it at all. That's prop drilling, and it gets worse and more brittle as an app grows.
With ChangeNotifierProvider registered near the top of the tree, this problem simply
disappears. Any widget, at any depth, can call context.watch<CartModel>() or
context.read<CartModel>() directly and get the shared instance — there's no
manual threading of parameters through intermediate widgets required. A deeply nested
CartBadge widget buried inside three other custom widgets can reach the same
CartModel as the top-level screen, with zero constructor changes anywhere in between.
// Deeply nested widget — no CartModel was ever passed to it
// as a constructor argument, yet it reads the shared state directly.
class CartBadge extends StatelessWidget {
const CartBadge({super.key});
@override
Widget build(BuildContext context) {
final count = context.watch<CartModel>().itemCount;
return Text('$count items');
}
}