07 · State Management Concepts¶
Flutter fundamentals used setState inside a
single State object — fine for state that only one widget cares about.
"State management" is really about one question: when data changes, how do
the (possibly many, possibly distant) widgets that display it find out?
Every Flutter state-management approach — ChangeNotifier, Provider,
Riverpod, Bloc — is a variation on the same core idea: an observable holder
of state, and something that rebuilds when it changes.
The core idea, without any package¶
Strip away the Flutter-specific names and it's the Observer pattern from design patterns: a value, a list of listeners, and a setter that notifies them.
class Store<T> {
Store(this._value);
T _value;
final List<void Function(T)> _listeners = [];
T get value => _value;
set value(T newValue) {
_value = newValue;
for (final listener in List.of(_listeners)) {
listener(newValue);
}
}
void addListener(void Function(T) listener) => _listeners.add(listener);
void removeListener(void Function(T) listener) => _listeners.remove(listener);
}
void main() {
final cart = Store<int>(0);
void onCartChanged(int items) => print('Cart badge: $items');
cart.addListener(onCartChanged);
cart.value = 1;
cart.value = 2;
cart.removeListener(onCartChanged);
cart.value = 3; // no listener fires -- nothing printed for this one
print('Final value: ${cart.value}');
}
// Cart badge: 1
// Cart badge: 2
// Final value: 3
Note the List.of(_listeners) when notifying — iterating a copy, not
the live list. That single line is the difference between this working and
the trap below.
The trap: mutating the listener list while notifying it¶
If a listener callback adds or removes a listener (very common — a widget unsubscribing itself, or subscribing a new one in response to a change), iterating the live list throws.
class BadStore<T> {
BadStore(this._value);
T _value;
final List<void Function(T)> _listeners = [];
set value(T newValue) {
_value = newValue;
// BUG: iterating the live list while a listener can mutate it.
for (final listener in _listeners) {
listener(newValue);
}
}
void addListener(void Function(T) listener) => _listeners.add(listener);
void removeListener(void Function(T) listener) => _listeners.remove(listener);
}
void main() {
final store = BadStore<int>(0);
late void Function(int) a;
a = (v) {
print('A saw $v, unsubscribing');
store.removeListener(a);
};
void b(int v) => print('B saw $v');
store.addListener(a);
store.addListener(b);
try {
store.value = 1;
} catch (e) {
print('Caught: $e');
}
}
// A saw 1, unsubscribing
// Caught: Concurrent modification during iteration: Instance(length:1) of '_GrowableList'.
B never gets notified at all — the exception cuts the loop short after
A mutates the list mid-iteration. This exact bug (iterating a live
subscriber list) is why Flutter's real ChangeNotifier internally
snapshots its listener list before calling them, same as List.of(...)
above.
Flutter's version: ChangeNotifier + ListenableBuilder¶
Flutter ships this pattern ready-made. ChangeNotifier provides
addListener/removeListener/notifyListeners(); ListenableBuilder
subscribes to a Listenable and rebuilds its builder on every
notification — no setState boilerplate required in the listening widget.
import 'package:flutter/material.dart';
class CartModel extends ChangeNotifier {
int _items = 0;
int get items => _items;
void add() {
_items++;
notifyListeners();
}
}
class CartBadge extends StatelessWidget {
const CartBadge({super.key, required this.model});
final CartModel model;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: model,
builder: (context, _) => Text('Items: ${model.items}'),
);
}
}
// test/cart_badge_test.dart -- verified with `flutter test`
testWidgets('CartBadge rebuilds when the model notifies', (tester) async {
final model = CartModel();
await tester.pumpWidget(MaterialApp(home: CartBadge(model: model)));
expect(find.text('Items: 0'), findsOneWidget);
model.add();
await tester.pump();
expect(find.text('Items: 1'), findsOneWidget);
});
CartBadge is a plain StatelessWidget — it holds no mutable state of its
own. ListenableBuilder is the piece doing the subscribing and rebuilding,
which is exactly the same shape as addListener/notify above, just wired
into Flutter's widget tree.
Where the bigger packages fit in¶
Provider, Riverpod, and Bloc all solve a problem this simple example
doesn't: how do deeply nested widgets get access to a Store/
ChangeNotifier without passing it down through every constructor? They
use InheritedWidget (a Flutter mechanism for making data available to any
descendant widget without threading it through parameters) plus varying
opinions on testability, boilerplate, and compile-time safety. The core
rebuild-on-notify mechanism underneath is the same one covered here.
Cheat sheet¶
| Concept | Meaning |
|---|---|
| Observable + listeners | Same Observer pattern as design patterns |
List.of(_listeners) before notifying |
Snapshot the list — avoids concurrent-modification when a listener mutates it |
ChangeNotifier |
Flutter's built-in observable base class |
notifyListeners() |
Tells every registered listener "rebuild now" |
ListenableBuilder |
Subscribes to a Listenable, rebuilds builder on notify |
InheritedWidget |
Makes data available to descendants without constructor threading |
| Provider / Riverpod / Bloc | Layer more structure/testability on top of the same notify-and-rebuild core |
How It Actually Works¶
ChangeNotifier is a small, concrete implementation of the observer
pattern built into Flutter's foundation library: it keeps a List of
listener closures, and notifyListeners() walks that list calling each one.
The mechanism that connects a ChangeNotifier to actual widget rebuilds —
ListenableBuilder (or AnimatedBuilder) — registers its own internal
callback as a listener in initState(), and that callback simply calls
setState(() {}) with an empty body. This is the entire bridge between
"my data changed" and "the screen updates": notifyListeners() fires the
listener, the listener calls setState, setState marks the Element
dirty, and the framework's normal build/reconcile pass (from the Flutter
fundamentals lesson) picks it up on the next frame — there's no special
"reactive" runtime machinery beneath the bigger state-management packages
(Provider, Riverpod, Bloc); they're all, at bottom, organizing this same
listener-registration-plus-setState mechanism more ergonomically and
with better scoping (so a change to one piece of state only rebuilds the
specific widgets that actually read it, rather than an entire subtree).
Mutating the listener list while notifyListeners() is iterating it is a
real hazard for the reason covered in the collections lessons: List
iteration holds an internal cursor that a concurrent add/remove on the
same list invalidates, throwing a ConcurrentModificationError — this is
precisely why a listener callback that itself calls
addListener/removeListener (common when a widget disposes mid-notification)
needs the notifier to iterate over a defensive copy, which is exactly what
Flutter's actual ChangeNotifier implementation does internally.
Exercise¶
Extend CartModel with a remove() method (clamped at 0, never negative)
and a computed String get summary that returns "empty" when items ==
0 and "$items item(s)" otherwise. Build a widget CartSummary using
ListenableBuilder to show summary, plus two buttons wired to add() and
remove(). Write a flutter_test that pumps the widget, taps add three
times and remove once, and asserts the summary reads "2 item(s)"; then
taps remove three more times and asserts it reads "empty" (never a
negative count).