The Observer Pattern in Dart — Listeners, ChangeNotifier, and Reactive State
One thing changes, and many things must react. That's the observer pattern — and it's the engine underneath ChangeNotifier, ValueNotifier, and Streams. Learn it once, and the whole of Flutter state management stops being mysterious.
The problem — being told instead of asking
One value changes, and a dozen things need to know. The counter goes up, and the label must redraw, the badge must update, the analytics must fire. So how do all those things find out?
The naive answer is to keep asking. You loop, you check, you compare against the last value you saw. "Changed yet? Changed yet?" That's called polling, and it's wasteful. Most of the time the answer is no, and every one of those checks was work done for nothing.
The observer pattern flips it around. Instead of everyone asking, the thing that owns the data does the telling. You register your interest once, then you wait. When the value actually changes, you get a call. Nothing in between.
That's the whole idea — pull becomes push. And it turns out to be one of the most important patterns in app development. Get this one and Flutter state management stops being mysterious.
The shape of the pattern — subject and observers
There are only two roles here. Get them straight and the rest falls into place.
The subject owns the state and keeps a list of everyone who cares about it.
The observers are those interested parties. Each one registers with the subject, and from then on the subject calls it back whenever something changes.
Look at the arrows. They all point one way — out from the subject. The subject holds references to its observers, but the observers don't know about each other. One rebuilds a widget, one writes a log, one saves to disk, and none of them has any idea the others exist.
That's the whole point: loose coupling. The subject depends on nothing but a list of callbacks. Want a fourth observer next week? Add it. You never touch the subject. The thing that changed and the things that react to it have been pulled apart, and that separation is what keeps a big app from turning into a tangle.
Building one from scratch
The fastest way to trust a pattern is to build the smallest version of it yourself. So let's write an observable counter by hand. You need three things: a list to hold the observers, a way to add to it, and a way to fire.
typedef Listener = void Function();
class Counter {
int _value = 0;
final List<Listener> _listeners = [];
int get value => _value;
void addListener(Listener fn) => _listeners.add(fn);
void removeListener(Listener fn) => _listeners.remove(fn);
void increment() {
_value++;
_notify();
}
void _notify() {
for (final fn in _listeners) {
fn();
}
}
}
That's the entire pattern. A list of callbacks, and a loop that runs them. Nothing more.
Notice the observer is just a function — a
void Function(). We met first-class functions a few episodes back, and this is where they pay off. The subject doesn't need an interface or a base class to call into. A plain function is enough.
Using it looks like this:
final counter = Counter();
counter.addListener(() => print('Label sees ${counter.value}'));
counter.addListener(() => print('Badge sees ${counter.value}'));
counter.increment();
// Label sees 1
// Badge sees 1
One call to increment, two reactions. Nobody asked whether the value changed — they were told. And if this shape looks familiar, it should. It's almost exactly what Flutter ships in its core library. Let's go meet it.
ChangeNotifier — the built-in subject
Good news: you don't have to write that counter by hand. Flutter already did. ChangeNotifier lives in package:flutter/foundation.dart, and it's the same idea with the rough edges filed off. It keeps the listener list, hands you addListener and removeListener, and gives you notifyListeners() to fire.
import 'package:flutter/foundation.dart';
class CartModel extends ChangeNotifier {
final List<String> _items = [];
List<String> get items => List.unmodifiable(_items);
void add(String item) {
_items.add(item);
notifyListeners(); // tell everyone the cart changed
}
}
You extend ChangeNotifier, mutate your own state, and call notifyListeners() when something worth announcing happens. That's the contract. The subject decides when a change matters — adding an item counts, reading the list doesn't.
On the other side, a widget listens. You rarely call
addListener yourself in UI code. Instead you hand the notifier to a builder that does it for you:
ListenableBuilder(
listenable: cart,
builder: (context, child) => Text('${cart.items.length} items'),
)
Every time the cart calls notifyListeners(), the builder reruns and the text updates. The widget asked for nothing. It was told, and it redrew. That's the observer pattern wearing a Flutter costume.
ValueNotifier and Streams — the same idea, narrower and wider
Once you can spot the pattern, you start seeing it everywhere in Dart. ChangeNotifier is the general case. It has two cousins, one on each side.
ValueNotifier<T> is the narrow one — a subject that wraps exactly one value. You assign to .value, and it notifies for you. But only if the new value is actually different by ==.
final count = ValueNotifier<int>(0);
count.value = 1; // notifies
count.value = 1; // no change, no notification
Stream is the wide one — built for a sequence of values arriving over time, with proper support for async, errors, and completion. You met Streams in the async section. Now you can name what they really are: the observer pattern for data that keeps coming.
Three names, one pattern. The trick is to pick the narrowest one that fits:
• One value on screen? Reach for
ValueNotifier.
• A model with several fields?
ChangeNotifier.
• A feed of incoming events — sockets, sensors, database queries?
Stream.
The leak — listeners that outlive their observers
Here's the one rule that bites everyone at least once. When an observer registers, the subject now holds a reference to it. That reference is real, and the garbage collector respects it. So what happens when the observer's job is done but it never unregisters?
It can't be collected. The widget left the screen, but the notifier is still holding its callback — and through that callback, the whole State object stays in memory. Do this on a long-lived or global notifier and you've got a leak that grows every single time the screen opens.
The fix: register and unregister come in pairs. Whatever you start in initState, you tear down in dispose. Flutter hands you the exact place to put it.
@override
void initState() {
super.initState();
widget.cart.addListener(_onCartChanged);
}
@override
void dispose() {
widget.cart.removeListener(_onCartChanged);
super.dispose();
}
One more half. For a ChangeNotifier you created, also call dispose() on the notifier itself when you're done with it, so its own listener list is released. And the builder widgets — ListenableBuilder, ValueListenableBuilder — do all of this for you. That's the real reason to reach for them instead of wiring listeners by hand.
Why this is the backbone of Flutter state management
Step back, and the whole landscape snaps into focus. Every Flutter state-management approach is the observer pattern with different ergonomics bolted on top. Every single one.
setState is the pattern at its smallest — the widget's Element is the observer, and calling setState marks it dirty so it rebuilds.
Provider wraps a ChangeNotifier and uses an InheritedWidget to deliver it down the tree, so descendants can observe it without being handed the object by hand.
Riverpod and Bloc lean on Stream-shaped notifications under the surface.
Different names, different conveniences, one engine.
// The same loop, every time:
// 1. something observes a subject
// 2. the subject's state changes
// 3. the subject notifies
// 4. the observer rebuilds
So when you sit down to pick a state-management library, you're not choosing a new idea. You're choosing how much ceremony you want around an idea you already understand. That's the payoff of learning the pattern before the packages — every one of them suddenly reads like a variation on a theme you've already seen. The step into Provider from here is a short one.
Test your understanding
7 questions
Seven questions on the observer pattern and the Dart and Flutter tools built on it — polling, notification, ChangeNotifier, ValueNotifier, Streams, and the listener leak.