Flutter ChangeNotifier and Provider Basics From Scratch

Coding Liquids blog cover featuring Sagnik Bhattacharya for the Flutter ChangeNotifier and Provider guide, with a model emitting notifyListeners and a Consumer widget rebuilding.
Coding Liquids blog cover featuring Sagnik Bhattacharya for the Flutter ChangeNotifier and Provider guide.

When one value is no longer enough — your screen needs a cart, a user session, or a form model that several widgets read and write — Provider with a ChangeNotifier is the official, low-ceremony answer. You write a plain Dart class that holds the state and shouts notifyListeners when it changes, expose it once with ChangeNotifierProvider, and read it anywhere below with context.watch or context.read. This tutorial builds the whole thing from scratch: the model, the package, the wiring, reading versus watching, Consumer and Selector for scoped rebuilds, disposal, and how it compares to ValueNotifier — all with paste-ready code.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Every snippet below is paste-ready against current stable Flutter. Provider is built on the InheritedWidget machinery, so if you have read that guide the wiring here will feel familiar.

Follow me on Instagram@sagnikteaches

I post bite-size Provider tips there — the watch-versus-read distinction trips up almost everyone at first.

Connect on LinkedInSagnik Bhattacharya

On LinkedIn I write about choosing between Provider, Riverpod and BLoC for real production teams.

Subscribe on YouTube@codingliquids

ChangeNotifier: a model that broadcasts

Start with a plain class that extends ChangeNotifier. Keep your fields private, expose read-only getters, and call notifyListeners after every change so subscribers rebuild.

import 'package:flutter/foundation.dart';

class CartModel extends ChangeNotifier {
  final List<String> _items = [];

  List<String> get items => List.unmodifiable(_items);
  int get count => _items.length;

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

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

That is the entire model. It has no idea about widgets — it just holds state and announces changes. Returning an unmodifiable view of the list stops the UI mutating your state behind your back, which keeps every change funnelled through methods that call notifyListeners.

Add the provider package

provider is a package, not part of the SDK, so add it before you can use the widgets below. Run flutter pub add provider, or add it to pubspec.yaml by hand:

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.2

Then import it where you wire things up: import 'package:provider/provider.dart';. Provider gives you ChangeNotifierProvider, the context.watch and context.read extensions, Consumer, and Selector.

Wire it up with ChangeNotifierProvider

Place a ChangeNotifierProvider above the widgets that need the model. Use the create callback so the provider owns the model's lifecycle and disposes it for you.

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CartModel(),
      child: const MyApp(),
    ),
  );
}

For several models, use MultiProvider with a list of providers. Put the provider high enough that every consumer sits beneath it, but no higher — scoping it to the relevant screen keeps disposal tidy.

The Complete Flutter Guide course thumbnail

Build real apps with Provider and Riverpod

The Complete Flutter Guide takes ChangeNotifier from a counter to full app architecture inside shipped, production-grade apps.

Enrol now

Read it: watch vs read

This is the distinction that decides whether your app rebuilds correctly. Use context.watch<T>() inside build to read data and subscribe — the widget rebuilds whenever the model calls notifyListeners. Use context.read<T>() in callbacks to call methods without subscribing.

@override
Widget build(BuildContext context) {
  // watch: rebuilds when the cart changes
  final count = context.watch<CartModel>().count;

  return Column(
    children: [
      Text('Items: $count'),
      ElevatedButton(
        // read: just call a method, no subscription
        onPressed: () => context.read<CartModel>().add('Coffee'),
        child: const Text('Add'),
      ),
    ],
  );
}

The rule of thumb: watch in build for display, read in callbacks for actions. Calling watch inside an onPressed throws, and using read for displayed data means your UI never updates — both are the classic Provider bugs.

Consumer and Selector for scoped rebuilds

Because context.watch rebuilds the whole build method, a large screen rebuilds entirely for one small change. Consumer narrows that: wrap only the widget that needs the data, and pass static parts through its child argument.

Consumer<CartModel>(
  child: const Icon(Icons.shopping_cart), // built once
  builder: (context, cart, child) {
    return Row(children: [child!, Text('${cart.count}')]);
  },
)

To go further still, Selector rebuilds only when a specific slice of the model changes, ignoring unrelated updates:

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

Here the Text rebuilds only when count changes, even if other fields on CartModel change and call notifyListeners.

Dispose and common pitfalls

When the model is created via ChangeNotifierProvider's create callback, the provider disposes it automatically when it leaves the tree — you do not call dispose yourself. Override dispose in your model only to clean up things it owns, such as a TextEditingController or a stream subscription, and remember to call super.dispose().

@override
void dispose() {
  _controller.dispose(); // your own resources
  super.dispose();
}

One trap to avoid: do not pass an already-created instance with ChangeNotifierProvider.value and expect it to be disposed — the .value constructor never disposes, by design, because it does not own the object. Use create when the provider should own the model's life.

ChangeNotifier vs ValueNotifier

A ValueNotifier is the right tool for one value — a single counter or toggle — and is in fact a ChangeNotifier under the hood. A ChangeNotifier shines when several related fields move together and you want methods, getters, and validation in one model. If a cart's items, count, and total all derive from the same list, a single ChangeNotifier keeps them coherent, whereas three separate ValueNotifiers would drift apart. Start with the lighter option and graduate to a ChangeNotifier model when the state becomes a cluster rather than a value.

A complete model: a shopping cart

The counter-style example earlier shows the shape, but a production model usually holds a collection and derives values from it. Here is a fuller CartModel that keeps a private list, exposes it read-only, and recalculates a total on demand. Every mutation funnels through a method that ends in notifyListeners, so the list can never change without the UI hearing about it.

import 'dart:collection';
import 'package:flutter/foundation.dart';

class CartItem {
  const CartItem(this.name, this.price);
  final String name;
  final double price;
}

class CartModel extends ChangeNotifier {
  final List<CartItem> _items = [];

  // Read-only view: callers can iterate but never mutate.
  UnmodifiableListView<CartItem> get items => UnmodifiableListView(_items);

  int get itemCount => _items.length;

  double get total =>
      _items.fold(0, (sum, item) => sum + item.price);

  void add(CartItem item) {
    _items.add(item);
    notifyListeners();
  }

  void remove(CartItem item) {
    _items.remove(item);
    notifyListeners();
  }

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

Why bother with UnmodifiableListView? If the getter returned _items directly, a widget could call cart.items.add(...) and change your state without ever touching add — so notifyListeners would never fire and the screen would silently fall out of sync. Handing back an unmodifiable view makes that mistake throw at runtime, which forces every change through a method that notifies. The total getter derives from the same list, so it can never drift away from items or itemCount.

Selector: rebuild on one field

A Consumer or a context.watch rebuilds for any call to notifyListeners. If a badge only shows the item count, you do not want it rebuilding every time a price changes or an item's quantity is edited. Selector fixes that: it takes a selector that extracts one value, and rebuilds only when that value changes.

// Rebuilds only when itemCount changes — not on every cart change.
Selector<CartModel, int>(
  selector: (_, cart) => cart.itemCount,
  builder: (context, count, child) => Badge(
    label: Text('$count'),
    child: child,
  ),
  child: const Icon(Icons.shopping_cart),
)

Contrast that with Consumer<CartModel> or context.watch<CartModel>(), which would rebuild this badge whenever anything on the cart changed — including a total recalculation that does not affect the count. Selector compares the extracted value with the previous one (using == by default) and skips the rebuild when they match. Keep the selected value cheap and comparable: return an int or a String rather than a fresh list, since two different list instances are never equal and the optimisation would be lost.

MultiProvider for several models

Real apps carry more than one model — a cart, a user session, a settings store. Nesting providers by hand quickly becomes a staircase, so MultiProvider flattens them into a list at the app root. Order does not matter unless one provider depends on another (covered next).

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => AuthModel()),
        ChangeNotifierProvider(create: (_) => CartModel()),
      ],
      child: const MyApp(),
    ),
  );
}

Every widget below MyApp can now reach either model with context.watch<AuthModel>() or context.read<CartModel>(). Each provider still owns its model's lifecycle and disposes it independently when the tree is torn down.

ProxyProvider: one model needs another

Sometimes one model has to be built from another — say a CartModel that needs the signed-in user from AuthModel to scope a server cart. ChangeNotifierProxyProvider handles that: create builds the dependent model once, and update re-runs whenever the upstream model changes, handing the latest value in.

MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => AuthModel()),
    ChangeNotifierProxyProvider<AuthModel, CartModel>(
      create: (_) => CartModel(),
      update: (_, auth, cart) => cart!..updateUser(auth.userId),
    ),
  ],
  child: const MyApp(),
)

The update callback receives the existing cart instance, so reuse it rather than constructing a new one — that keeps its listeners intact. Give CartModel an updateUser method that stores the id and calls notifyListeners only if it actually changed, to avoid needless rebuilds.

Test a ChangeNotifier

Because a ChangeNotifier is plain Dart with no widget dependencies, you can unit-test it without pumping a widget tree. Construct the model, attach a listener that counts calls, mutate it, then assert both the resulting state and that notifyListeners fired the expected number of times.

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('adding an item updates state and notifies', () {
    final cart = CartModel();
    var notifications = 0;
    cart.addListener(() => notifications++);

    cart.add(const CartItem('Coffee', 3.5));

    expect(cart.itemCount, 1);
    expect(cart.total, 3.5);
    expect(notifications, 1); // notifyListeners fired exactly once
  });
}

This runs with flutter test and needs no emulator. Asserting on the listener count, not just the final state, catches the subtler bug where a mutator changes a field but forgets to notify — the state looks right while the UI would never have rebuilt.

Common mistakes

  • Forgetting notifyListeners. Changing a field without calling it means nothing rebuilds — every mutator must notify.
  • watch in a callback, read in build. Use watch in build for display and read in callbacks for actions; swapping them breaks updates or throws.
  • Watching the whole screen for one field. Use Consumer or Selector to rebuild only the part that depends on the data.
  • Disposing a provider-owned model. The provider's create handles disposal; only clean up resources the model itself owns.
  • Provider placed below its consumers. The ChangeNotifierProvider must sit above every widget that reads it, or the lookup fails.
  • Mutating state without notifying. Editing a field, list, or map and skipping notifyListeners leaves the UI showing stale data — the model changed but no listener was told.
  • Notifying after dispose. Calling notifyListeners once the model has been disposed throws; cancel timers, streams, and async callbacks in dispose so nothing fires late.
  • Exposing a mutable collection. Returning your private List or Map directly lets callers change state without going through a method, so the change never notifies — hand back an UnmodifiableListView instead.

Frequently asked questions

What is ChangeNotifier in Flutter?

It is a class from the Flutter foundation library that holds state and lets widgets subscribe to it. You extend it, expose your data, and call notifyListeners whenever the data changes so listeners rebuild. It is the model class the Provider package is built to work with.

What is the difference between context.watch and context.read?

context.watch subscribes the widget and rebuilds it when notifyListeners fires, so use it in build to display data. context.read fetches the model once without subscribing, so use it in callbacks like onPressed to call methods.

When should I use Consumer instead of watch?

Use Consumer when you want to rebuild only a small part of a larger widget rather than the whole build method that context.watch would rebuild. Wrap just the widget that needs the data and pass static parts via its child argument so they are not rebuilt.

Do I need to dispose a ChangeNotifier?

If you create it through ChangeNotifierProvider's create callback, the provider disposes it for you. If you create it manually and add listeners by hand, you must dispose it and remove those listeners. Override dispose in your model to clean up any controllers or subscriptions it owns.

Further reads

Keep going with the tutorials that pair with this guide:

Sources: Flutter documentation — ChangeNotifier and the Simple app state management guide; the provider package (docs.flutter.dev, pub.dev/packages/provider). Verified against current stable Flutter.