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."

Where this fits: setState is perfect for state that belongs to a single widget. The moment two or more widgets in different parts of the tree need to share and react to the same piece of state, Provider becomes the natural next step.

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();
  }
}
Note: _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.

MethodWhat it doesWhen to use it
context.watch<CartModel>()Subscribes this widget to the model — whenever notifyListeners() fires, this widget's build() runs againInside 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 changesInside 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),
      ),
    );
  }
}
Why this distinction matters: if you called 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');
  }
}
Next: Provider is a fantastic default for most apps, but it's not the only game in town. Continue to State Management: Riverpod & Bloc to see two more approaches — one that fixes some of Provider's rough edges, and one built for larger, more structured codebases.