ChangeNotifier: Simple Observable State
After 26 days of building Flutter apps, you've probably noticed that managing state across widgets gets messy fast. ChangeNotifier is a lightweight, built-in solution that turns a regular class into an observable that notifies listeners whenever something changes. It's the foundation of Provider, GetX, and many other state management systems, and understanding it deeply will make you a much more confident developer when building real production applications.
Why ChangeNotifier Matters
Before we dive into code, let me explain why ChangeNotifier exists. In Flutter, widgets rebuild when their state changes. But state often lives outside individual widgets—it belongs to your entire app or a feature. You need a way to:
- Hold business logic and data in one place
- Notify widgets when that data changes
- Do this without coupling widgets tightly to each other
- Keep performance reasonable by only rebuilding what needs rebuilding
ChangeNotifier solves all four problems. It's simple, performant for most use cases, and it teaches you the observer pattern—something you'll recognize in dozens of other tools.
Extend ChangeNotifier
The first step is creating a class that extends ChangeNotifier. This is where your business logic and state live. I call these "notifiers" or "providers," and I keep them completely separate from my widgets.
Let's build a realistic example: a shopping cart. Users add items, change quantities, and see the total update everywhere in the app.
class CartNotifier extends ChangeNotifier {
final List<CartItem> _items = [];
List<CartItem> get items => _items;
double get total => _items.fold(
0.0,
(sum, item) => sum + (item.price * item.quantity),
);
void addItem(CartItem item) {
final index = _items.indexWhere((i) => i.id == item.id);
if (index >= 0) {
_items[index].quantity += 1;
} else {
_items.add(item);
}
}
void removeItem(String itemId) {
_items.removeWhere((item) => item.id == itemId);
}
void clearCart() {
_items.clear();
}
}Notice what's missing: there's no notifyListeners() call yet. The class extends ChangeNotifier and has getters for its state, but it doesn't notify anyone of changes. That's the next step.
Call notifyListeners After Changes
Whenever you modify state, you must call notifyListeners(). This tells all listening widgets "something changed, rebuild yourself." Without it, your UI won't update even though the data did.
class CartNotifier extends ChangeNotifier {
final List<CartItem> _items = [];
List<CartItem> get items => _items;
double get total => _items.fold(
0.0,
(sum, item) => sum + (item.price * item.quantity),
);
void addItem(CartItem item) {
final index = _items.indexWhere((i) => i.id == item.id);
if (index >= 0) {
_items[index].quantity += 1;
} else {
_items.add(item);
}
notifyListeners();
}
void removeItem(String itemId) {
_items.removeWhere((item) => item.id == itemId);
notifyListeners();
}
void updateQuantity(String itemId, int quantity) {
final item = _items.firstWhereOrNull((i) => i.id == itemId);
if (item != null) {
item.quantity = quantity;
notifyListeners();
}
}
void clearCart() {
_items.clear();
notifyListeners();
}
}Now every method that changes state ends with notifyListeners(). This is critical. In my experience, 90% of state management bugs come from forgetting this single line.
Here's how you use it in a widget with Consumer (from the Provider package):
class CartScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cart')),
body: Consumer<CartNotifier>(
builder: (context, cart, _) {
if (cart.items.isEmpty) {
return const Center(child: Text('Cart is empty'));
}
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: cart.items.length,
itemBuilder: (context, index) {
final item = cart.items[index];
return ListTile(
title: Text(item.name),
subtitle: Text('\$${item.price}'),
trailing: Text('x${item.quantity}'),
onLongPress: () => cart.removeItem(item.id),
);
},
),
),
Container(
padding: const EdgeInsets.all(16),
child: Text('Total: \$${cart.total.toStringAsFixed(2)}'),
),
],
);
},
),
);
}
}When you call cart.addItem() or cart.removeItem(), the notifier calls notifyListeners(), which rebuilds the Consumer widget automatically. The UI stays in sync with your data.
Keep Business Logic Outside Widgets
This is where ChangeNotifier shines for larger teams and codebases. Your notifiers don't know about Flutter or widgets at all. They're just Dart classes with methods and getters. This makes them:
- Testable: Write unit tests without mocking BuildContext or widgets
- Reusable: Use the same notifier in different UIs
- Maintainable: Business logic doesn't scatter across your widget tree
Let me show you a more realistic notifier with actual business logic:
class OrderNotifier extends ChangeNotifier {
final OrderRepository _repository;
List<Order> _orders = [];
bool _isLoading = false;
String? _error;
OrderNotifier(this._repository);
List<Order> get orders => _orders;
bool get isLoading => _isLoading;
String? get error => _error;
Future<void> fetchOrders() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_orders = await _repository.getOrders();
_error = null;
} catch (e) {
_error = e.toString();
_orders = [];
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> cancelOrder(String orderId) async {
try {
await _repository.cancelOrder(orderId);
_orders.removeWhere((o) => o.id == orderId);
notifyListeners();
} catch (e) {
_error = 'Failed to cancel order: ${e.toString()}';
notifyListeners();
}
}
}Notice: the notifier talks to a repository, handles errors, manages loading states, and manipulates data. It never imports flutter/material.dart or references widgets. This is how you write code that survives refactoring.
Testing it is trivial:
void main() {
test('OrderNotifier fetches orders correctly', () async {
final mockRepo = MockOrderRepository();
final notifier = OrderNotifier(mockRepo);
await notifier.fetchOrders();
expect(notifier.orders.length, 3);
expect(notifier.isLoading, false);
expect(notifier.error, null);
});
}Dispose Notifiers You Create
This is the part junior developers often skip, and it causes memory leaks. Every ChangeNotifier you create holds listeners in memory. When your notifier is no longer needed, you must call dispose() to clean up.
If you're using Provider (which I recommend), it handles this for you automatically:
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CartNotifier()),
ChangeNotifierProvider(create: (_) => OrderNotifier(orderRepository)),
],
child: const MyApp(),
),
);
}Provider creates the notifiers when the app starts and disposes them when they're no longer needed. You don't manually call dispose().
But if you're managing notifiers manually (which I sometimes do for simpler features), you must dispose them yourself:
class MyStatefulWidget extends StatefulWidget {
@override
State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
late CartNotifier _cartNotifier;
@override
void initState() {
super.initState();
_cartNotifier = CartNotifier();
_cartNotifier.addListener(_onCartChanged);
}
void _onCartChanged() {
setState(() {});
}
@override
void dispose() {
_cartNotifier.removeListener(_onCartChanged);
_cartNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Use _cartNotifier here
return Container();
}
}The key is: if you call addListener(), you must call removeListener() in dispose. If you create a ChangeNotifier, you must call dispose() on it. This prevents memory leaks and crashes.
Common Mistakes
Forgetting notifyListeners(): Your state changes but the UI doesn't rebuild. I've spent hours debugging this. Train yourself to end every mutation with notifyListeners().
Mutating state without rebuilding: Don't do this:
// Wrong
void addToCart(Item item) {
_items.add(item);
// Forgot notifyListeners()!
}Always notify after mutation.
Not disposing notifiers: This leaks memory and keeps old listeners alive. Always dispose in a StatefulWidget or use Provider to handle it automatically.
Putting UI logic in notifiers: Don't do this:
// Wrong
void checkout() {
// Don't navigate here
Navigator.push(...);
}Notifiers are for data and business logic. Navigation, dialogs, and snackbars belong in widgets.
Creating new notifiers every frame: Create them once in initState or via Provider, then reuse them. Creating a new CartNotifier() on every build is wasteful and breaks state.
Key Takeaways
- Extend ChangeNotifier to create observable classes that hold state and business logic
- Call
notifyListeners()after every change to notify listening widgets - Keep business logic, data, and API calls in notifiers—never in widgets
- Always dispose notifiers you manually create, or use Provider to handle disposal automatically
- Test notifiers as plain Dart classes with unit tests; they don't depend on Flutter
- Pair ChangeNotifier with Consumer or Provider.of to rebuild only what changed






Comments
Loading comments…