← Back to blog
Flutter State Management · Part 3 of 7
September 15, 202611 min read

Provider: InheritedWidget With the Boilerplate Done For You

FlutterDartState Management

Provider: The Boilerplate, Done For You

This is Part 3 of the Flutter State Management series. In Part 2 you learned the engine — InheritedWidget — and saw its cost: writing the InheritedWidget, a StatefulWidget wrapper, the of method, and updateShouldNotify by hand for every piece of shared state. Tedious.

Provider is the package that writes all of that for you. For years it was the recommended Flutter state solution, and it's still everywhere. Crucially, because you understand Part 2, Provider holds no mystery: it's InheritedWidget + ChangeNotifier + a clean API. Let's make it click.

Version note: we're on provider 6.x, Flutter 3.38 / Dart 3.12. Provider is mature and stable, but for brand-new projects in 2026 the community generally reaches for Riverpod (Part 4) — we'll be honest about why. You'll still meet Provider in countless existing codebases, so it's essential knowledge.


The mental model: a facilities manager for the notice board

In Part 2 the InheritedWidget was a notice board, and you had to bolt it up, hire a StatefulWidget to keep it updated, and write the lookup rules.

Provider is the facilities manager. You hand it the thing to display (an object that can notify when it changes), and Provider handles posting it, updating it, notifying readers, and even tearing it down. You just say "give me the value" or "rebuild me when it changes."

One-line truth: Provider is a thin, ergonomic wrapper over InheritedWidget. ChangeNotifierProvider is the ChangeNotifier + InheritedNotifier combo from the end of Part 2, packaged. Nothing new conceptually — just no boilerplate.


Step 1: a model that notifies — ChangeNotifier

Provider needs a source of truth that can announce changes. The standard one is a ChangeNotifier: a class that calls notifyListeners() whenever its data changes.

import 'package:flutter/foundation.dart';

class CartModel extends ChangeNotifier {
  final List<String> _items = [];
  List<String> get items => List.unmodifiable(_items); // expose read-only

  int get count => _items.length;

  void add(String item) {
    _items.add(item);
    notifyListeners(); // 🔔 "anyone listening, I changed — rebuild!"
  }

  void clear() {
    _items.clear();
    notifyListeners();
  }
}

The contract: mutate your data, then call notifyListeners(). Forget it and the UI won't update (the #1 Provider bug). It's the same role setState played in Part 1 — "I changed, please refresh" — but decoupled from any single widget.


Step 2: expose it — ChangeNotifierProvider

You place the model above the widgets that need it, exactly where you'd have put the InheritedWidget:

ChangeNotifierProvider(
  create: (context) => CartModel(), // built once, lazily; auto-disposed for you
  child: const ShopScreen(),
)
  • create builds the model once and Provider disposes it automatically when the provider leaves the tree (it calls dispose() on your ChangeNotifier). No leak — contrast Part 1's manual dispose.
  • Need several? Use MultiProvider to avoid a nesting pyramid:
MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => CartModel()),
    ChangeNotifierProvider(create: (_) => AuthModel()),
    Provider(create: (_) => ApiClient()), // plain value, no notifications
  ],
  child: const App(),
)

create vs .value: use create: to let Provider own and dispose a freshly-built object. Use ChangeNotifierProvider.value(value: existing) only when the object's lifecycle is owned elsewhere (e.g. passing an existing instance to a list item) — Provider then won't dispose it. Misusing .value with a freshly-created object is a classic leak/duplicate-dispose bug.


Step 3: read it — watch, read, select (the part that matters most)

This is the heart of Provider, and it maps directly onto Part 2's "subscribe or not" distinction. Get this right and you'll avoid 90% of Provider performance complaints.

| API | Subscribes (rebuilds)? | Use it in | Maps to Part 2 | | --- | --- | --- | --- | | context.watch<T>() | ✅ Yes | build() | dependOnInheritedWidgetOfExactType | | context.read<T>() | ❌ No | callbacks, initState | non-subscribing read | | context.select<T, R>(...) | ✅ but only on the slice | build() | updateShouldNotify on one field |

@override
Widget build(BuildContext context) {
  // watch: rebuild this widget whenever CartModel notifies.
  final count = context.watch<CartModel>().count;
  return Text('Cart: $count');
}
// read: in a callback you just want to CALL a method, not subscribe.
onPressed: () => context.read<CartModel>().add('Apple'),

The cardinal rule: watch in build, read in callbacks. Calling watch in an onPressed is wrong (you don't want the button to rebuild on every change), and calling read in build means you won't rebuild when data changes. This is the exact same lesson as Part 2's subscribe-vs-read, now with friendlier names.

select — rebuild on one field only

watch rebuilds whenever the model notifies for any reason. If your widget only cares about count, but the model also notifies on unrelated changes, you over-rebuild. select subscribes to a derived slice:

// Rebuilds ONLY when count changes, ignoring other CartModel notifications.
final count = context.select<CartModel, int>((cart) => cart.count);

This is the precision tool — Provider's version of a tight updateShouldNotify. Reach for it when a widget needs one field of a chunky model.


Consumer and Selector — scoping rebuilds in the tree

context.watch rebuilds the whole build() it's called in. If only a small part of a big widget needs the value, wrap just that part in a Consumer so only it rebuilds:

Column(
  children: [
    const ExpensiveHeader(),            // never rebuilds — no Provider access
    Consumer<CartModel>(
      builder: (context, cart, child) => Text('Cart: ${cart.count}'),
      // 'child' is for subtrees that DON'T depend on the model — built once:
      child: const Icon(Icons.shopping_cart),
    ),
  ],
)

Why Consumer exists: it lets you put the rebuild boundary low in the tree so an expensive sibling (ExpensiveHeader) isn't rebuilt. The optional child parameter is an optimization — pass any subtree that doesn't depend on the model and Provider reuses it across rebuilds instead of rebuilding it.

Selector<T, R> is to Consumer what select is to watch — it rebuilds only when a chosen slice changes:

Selector<CartModel, int>(
  selector: (context, cart) => cart.count,
  builder: (context, count, child) => Text('Cart: $count'),
)

A complete, tiny example

void main() => runApp(
  ChangeNotifierProvider(
    create: (_) => CartModel(),
    child: const MaterialApp(home: ShopScreen()),
  ),
);

class ShopScreen extends StatelessWidget {
  const ShopScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // Badge rebuilds only when count changes:
      appBar: AppBar(title: Selector<CartModel, int>(
        selector: (_, c) => c.count,
        builder: (_, count, __) => Text('Cart ($count)'),
      )),
      body: Center(
        child: ElevatedButton(
          // read: fire a method, don't subscribe:
          onPressed: () => context.read<CartModel>().add('Apple'),
          child: const Text('Add apple'),
        ),
      ),
    );
  }
}

Tap the button → add mutates and notifyListeners() fires → only the Selector badge rebuilds. No prop drilling, no manual InheritedWidget, automatic disposal. That's Provider's pitch.


Beyond ChangeNotifier

Provider isn't only for ChangeNotifier. The family covers common shapes:

| Provider | For | | --- | --- | | Provider<T> | A plain value/service (no notifications) — DI, like an API client | | ChangeNotifierProvider<T> | A ChangeNotifier model | | FutureProvider<T> | Expose the result of a Future | | StreamProvider<T> | Expose values from a Stream | | ProxyProvider<A, B> | A provider whose value depends on another provider |


Where Provider starts to hurt (the honest part)

Provider is good, but it has real, well-known rough edges — and these are exactly what Riverpod was built to fix:

| Pain | Why it happens | | --- | --- | | Runtime ProviderNotFoundException | Reading a T with no provider above compiles fine, crashes at runtime. No compile-time safety. | | Type collisions | Two providers of the same type T are ambiguous — the tree can only find "the nearest T." | | BuildContext dependence | You need a context to read state, so you can't easily read providers outside the widget tree (e.g. in plain Dart logic). | | Nesting pyramids | Many interdependent providers get awkward even with MultiProvider. | | Easy to misuse | watch in a callback, forgetting notifyListeners, .value lifecycle mistakes. |

The takeaway: Provider's model — InheritedWidget + notifiers + context lookups — is solid and worth understanding, but its reliance on types in the tree and BuildContext makes whole classes of bugs runtime problems. Riverpod keeps the good ideas and moves those errors to compile time, without needing context. That's Part 4.


Practice Challenges

Challenge 1 — The silent UI. A ChangeNotifier's add() updates a list but the UI never refreshes. What's missing?

Show solution

A call to notifyListeners() after mutating. Without it, Provider has no idea the model changed, so no listener rebuilds:

void add(String x) { _items.add(x); notifyListeners(); }

Challenge 2 — watch or read? Fix this:

onPressed: () => context.watch<CartModel>().add('Pear'),
Show solution

Use read in a callback — you're calling a method, not subscribing:

onPressed: () => context.read<CartModel>().add('Pear'),

watch in a callback is wrong (and Provider will warn): callbacks shouldn't register rebuild dependencies.

Challenge 3 — Kill the over-rebuild. A big ProfilePage calls context.watch<UserModel>() just to show the user's name, and the whole page rebuilds whenever anything on the model changes. Improve it.

Show solution

Subscribe to just the name slice with select (or wrap only the name in a Selector):

final name = context.select<UserModel, String>((u) => u.name);

Now the page rebuilds only when name changes, not on every model notification.

Challenge 4 — Lifecycle. When do you use ChangeNotifierProvider(create: ...) vs ChangeNotifierProvider.value(value: ...)?

Show solution

Use create: when Provider should own and dispose a freshly-built model (the common case). Use .value only when the instance's lifecycle is managed elsewhere (e.g. an existing object passed into a list item) — Provider won't dispose it. Using .value with a newly-created object risks leaks/missing disposal.

Challenge 5 — Connect to Part 2. Explain how context.watch<T>() and context.read<T>() correspond to the two lookups from Part 2.

Show solution

watch corresponds to dependOnInheritedWidgetOfExactType — it subscribes, registering the element as a dependent so it rebuilds on change. read corresponds to the non-subscribing lookup (getInheritedWidgetOfExactType) — it reads the current value without registering a dependency. Provider just renames the Part 2 distinction.


Questions to test yourself

Q1 (basic). In one sentence, what is Provider in terms of Part 2?

Show answer

A package that wraps InheritedWidget (plus ChangeNotifier/notifiers) with a clean API, so you get shared state without hand-writing the InheritedWidget + StatefulWidget + of + updateShouldNotify boilerplate.

Q2 (basic). What must a ChangeNotifier call for the UI to update, and when?

Show answer

notifyListeners(), after mutating its data. That tells Provider's listeners (watchers/consumers) to rebuild. Forgetting it is the most common Provider bug.

Q3 (intermediate). State the rule for watch vs read and why.

Show answer

watch in build() (you want rebuilds when state changes), read in callbacks/initState (you just call a method or read once, and don't want to subscribe). Mixing them up causes either missing updates or needless rebuilds.

Q4 (intermediate). What does select / Selector do that watch / Consumer doesn't?

Show answer

They subscribe to a derived slice of the model and rebuild only when that slice changes, rather than on every notifyListeners(). It's the precision tool to avoid over-rebuilding when a widget needs one field of a larger model.

Q5 (advanced). Why does Provider's ProviderNotFoundException happen at runtime, and what's the deeper limitation it reveals?

Show answer

Provider looks up state by type through the BuildContext/tree, which the compiler can't verify — if no provider of that type is above the reader, it compiles fine but throws at runtime. The deeper limitation is that Provider has no compile-time safety and depends on the widget tree + context, which Riverpod fixes by making providers top-level objects checked at compile time.

Q6 (advanced). Why does Consumer's optional child parameter improve performance?

Show answer

The child is a subtree that doesn't depend on the model. Consumer builds it once and passes the same instance into every builder call, so it isn't rebuilt when the model changes — only the parts inside the builder that use the value rebuild. It's a way to keep static/expensive children out of the rebuild path.


Wrapping up

  • Provider = InheritedWidget + ChangeNotifier, packaged — shared state with no hand-written boilerplate and automatic disposal.
  • A ChangeNotifier holds the data and calls notifyListeners() on change.
  • Expose with ChangeNotifierProvider (create owns+disposes; .value for externally-owned objects); compose with MultiProvider.
  • Read with watch in build, read in callbacks, select for one slice; scope rebuilds with Consumer/Selector.
  • Its weaknesses — runtime ProviderNotFoundException, type collisions, context dependence — are precisely what the next tool fixes.

In Part 4 we meet the modern successor and why it exists: Riverpod — compile-safe, context-free providers — and how it improves on everything you just learned.