The Provider Pattern in Flutter: ChangeNotifier, context.watch, and scoping
provider 6.1.5+1 wraps InheritedWidget with ChangeNotifierProvider, context.watch and context.read, ProxyProvider, Selector, and MultiProvider scoping.
Published on • November 5, 2026
AI Assistant

Handing state down a deep widget tree with constructor parameters turns into callback spaghetti fast, and raw InheritedWidget is verbose to write and easy to misuse. The provider package sits between the two: a thin, typed wrapper that gives you allocation, disposal, lazy loading and a devtools-visible tree.
Current release provider 6.1.5+1 (published August 2025, MIT, verified publisher dash-overflow.net) supports Android, iOS, Linux, macOS, web and Windows, and its own README argues the case in five bullets: simplified allocation/disposal, lazy loading, far less boilerplate, devtools friendliness, and a common consumption API.
ChangeNotifierProvider: create, not .value
The Flutter team’s state management guide builds its whole tutorial on ChangeNotifier + ChangeNotifierProvider + Consumer, and tells newcomers that if they have no strong reason to pick another approach, this is where to start.
class CartModel extends ChangeNotifier {
final List<Item> _items = [];
int get totalPrice => _items.length * 42;
void add(Item item) {
_items.add(item);
notifyListeners();
}
}
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const MyApp(),
),
);
}
The provider README draws a hard line about constructors. Use create: when the provider should own the object — ChangeNotifierProvider then calls dispose() for you — and use .value only when you are exposing an instance you already own, because the default constructor may dispose an object that is still in use elsewhere. Two more rules: create/update callbacks run lazily until something reads the value (pass lazy: false to pre-compute), and never build a provider from a variable that changes over time — that is what ProxyProvider is for.
watch, read, and select: the rebuild rules
The package exposes three extension methods on BuildContext, and the lookup itself is O(1) — it does not walk the widget tree:
context.watch<T>()subscribes the widget to changes ofT.context.read<T>()returnsTwithout subscribing, and cannot be called fromStatelessWidget.build/State.build.context.select<T, R>((value) => r)rebuilds only when the extractedrchanges.
That distinction is why callbacks use read:
FloatingActionButton(
onPressed: () => context.read<CartModel>().add(dashItem),
child: const Icon(Icons.add),
)
Provider.of<T>(context) is the static equivalent — listen: true behaves like watch, listen: false like read. The same README warns against calling context.read(...).fetch() synchronously inside initState, since mutating state mid-build produces inconsistent frames; do it in the provider’s create, or defer with Future.microtask.
For narrow rebuilds, wrap only the part that needs the value:
Consumer<CartModel>(
builder: (context, cart, child) => Text('Total: ${cart.totalPrice}'),
child: const ExpensiveChart(), // built once, never rebuilt
)
The guide’s rule of thumb: push Consumer as deep as possible, and never rebuild a large subtree because one leaf value changed. When watch is too broad, context.select on a single property does the same job without a widget.
Scoping: MultiProvider, ProxyProvider, and type collisions
MultiProvider is purely cosmetic — it flattens nesting without changing behavior:
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CartModel()),
Provider(create: (_) => ApiClient()),
ProxyProvider<ApiClient, Repository>(
update: (_, api, __) => Repository(api),
),
],
child: const MyApp(),
)
ProxyProvider recomputes a derived object whenever a provider it depends on updates; the digit in ProxyProvider2, ProxyProvider3 is the number of inputs, and ChangeNotifierProxyProvider feeds the result into a ChangeNotifierProvider instead. Two gotchas from the README: a widget can only ever read the nearest provider of a type, so two Provider<String> ancestors resolve to one — give them distinct types instead; and optional dependencies are expressed by making the generic nullable, context.watch<Model?>(), which returns null instead of throwing ProviderNotFoundException.
Testing and debugging
ChangeNotifier lives in flutter:foundation, so most of it is unit-testable without any widget harness:
test('adding an item notifies listeners', () {
final cart = CartModel();
var notifications = 0;
cart.addListener(() => notifications++);
cart.add(Item('Dash'));
expect(notifications, 1);
});
Widget tests just swap the app root for a ChangeNotifierProvider with a fake model, and because providers are ordinary widgets they show up in the Flutter devtools tree with their exposed values. If you want richer output than Instance of CartModel, mix in DiagnosticableTreeMixin and implement debugFillProperties.
There are limits worth knowing. The README notes that dispatching to listeners is O(N), and that around 150+ providers mounted at once some devices throw a StackOverflowError — mount them progressively during a splash screen, or drop MultiProvider, which inserts a widget between every provider.
When Riverpod or Bloc is the better fit
The provider README itself lists flutter_bloc and MobX as architectures that build on top of provider, so adopting one later does not mean throwing this pattern away. flutter_riverpod 3.4.3 is the usual next step when you want compile-safe overrides and async state handled for you — its ConsumerWidget takes a ref instead of a BuildContext, and AsyncData/AsyncError switch cases replace manual loading flags.
Provider is still enough when your app has a handful of ChangeNotifiers, one or two dependencies to inject, and no need for code generation. Reach for something heavier only once you find yourself fighting the rebuild granularity or duplicating loading and error handling across screens.
Takeaway: Create objects with create: (never .value), subscribe with context.watch in build and context.read in callbacks, narrow rebuilds with context.select or Consumer, and keep provider until async loading state or override-based testing is what actually hurts.