ChangeNotifier Pattern
What is ChangeNotifier?
ChangeNotifier is a class provided by the Flutter framework that implements the Listenable interface. It maintains a list of listeners and provides the notifyListeners() method to notify all registered listeners when state changes. It is the foundation for most state management approaches in Flutter, from simple InheritedNotifier setups to the popular Provider package.
Basic ChangeNotifier Structure
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners(); // Notify all listeners of the change
}
void decrement() {
_count--;
notifyListeners();
}
}
ChangeNotifier follows the Observer pattern. Widgets (observers) register interest in a notifier (subject). When the notifier’s state changes, all observers are automatically updated. This decouples the UI from the business logic.
notifyListeners() in Detail
The notifyListeners() method iterates through all registered listeners and calls each one. It is safe to call during a build, but you should only call it when the state has actually changed to avoid unnecessary rebuilds.
Conditional Notification
class SettingsModel extends ChangeNotifier {
String _locale = 'en';
bool _darkMode = false;
String get locale => _locale;
bool get darkMode => _darkMode;
void setLocale(String newLocale) {
if (_locale != newLocale) {
_locale = newLocale;
notifyListeners(); // Only notify if the value actually changed
}
}
void toggleDarkMode() {
_darkMode = !_darkMode;
notifyListeners();
}
// Batch multiple changes into a single notification
void applySettings({required String locale, required bool darkMode}) {
_locale = locale;
_darkMode = darkMode;
notifyListeners(); // Single notification for multiple changes
}
}
notifyListeners() once at the end. This avoids multiple rebuild cycles for a single logical change.
addListener and removeListener
Under the hood, widgets like ValueListenableBuilder and AnimatedBuilder call addListener to subscribe and removeListener to unsubscribe. You can also use these methods directly for custom scenarios.
Manual Listener Management
class TimerWidget extends StatefulWidget {
final CounterModel counter;
const TimerWidget({super.key, required this.counter});
@override
State<TimerWidget> createState() => _TimerWidgetState();
}
class _TimerWidgetState extends State<TimerWidget> {
int _lastKnownCount = 0;
@override
void initState() {
super.initState();
_lastKnownCount = widget.counter.count;
widget.counter.addListener(_onCounterChanged);
}
void _onCounterChanged() {
setState(() {
_lastKnownCount = widget.counter.count;
});
}
@override
void dispose() {
widget.counter.removeListener(_onCounterChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text('Count: \$_lastKnownCount');
}
}
addListener call must have a corresponding removeListener call. If you forget to remove a listener, the callback will continue to be called even after the widget is disposed, which can cause setState called after dispose errors and memory leaks.
Creating Model Classes with ChangeNotifier
The real power of ChangeNotifier emerges when you create dedicated model classes that encapsulate all business logic for a particular domain. The UI simply reads state and calls methods — no business logic lives in widgets.
Todo List Model
class Todo {
final String id;
final String title;
bool isCompleted;
final DateTime createdAt;
Todo({
required this.id,
required this.title,
this.isCompleted = false,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
}
class TodoListModel extends ChangeNotifier {
final List<Todo> _todos = [];
List<Todo> get todos => List.unmodifiable(_todos);
List<Todo> get completedTodos =>
_todos.where((t) => t.isCompleted).toList();
List<Todo> get pendingTodos =>
_todos.where((t) => !t.isCompleted).toList();
int get totalCount => _todos.length;
int get completedCount => completedTodos.length;
void addTodo(String title) {
_todos.add(Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
));
notifyListeners();
}
void toggleTodo(String id) {
final index = _todos.indexWhere((t) => t.id == id);
if (index >= 0) {
_todos[index].isCompleted = !_todos[index].isCompleted;
notifyListeners();
}
}
void removeTodo(String id) {
_todos.removeWhere((t) => t.id == id);
notifyListeners();
}
void clearCompleted() {
_todos.removeWhere((t) => t.isCompleted);
notifyListeners();
}
}
Separating Business Logic from UI
With ChangeNotifier models, your widgets become thin rendering layers. They read data from the model and call model methods in response to user actions. This separation makes code easier to test, maintain, and reuse.
Auth State Model
enum AuthStatus { unauthenticated, loading, authenticated, error }
class AuthModel extends ChangeNotifier {
AuthStatus _status = AuthStatus.unauthenticated;
String? _userId;
String? _email;
String? _errorMessage;
AuthStatus get status => _status;
String? get userId => _userId;
String? get email => _email;
String? get errorMessage => _errorMessage;
bool get isAuthenticated => _status == AuthStatus.authenticated;
Future<void> login(String email, String password) async {
_status = AuthStatus.loading;
_errorMessage = null;
notifyListeners();
try {
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
if (email.isNotEmpty && password.length >= 6) {
_userId = 'user_123';
_email = email;
_status = AuthStatus.authenticated;
} else {
throw Exception('Invalid credentials');
}
} catch (e) {
_status = AuthStatus.error;
_errorMessage = e.toString();
}
notifyListeners();
}
void logout() {
_userId = null;
_email = null;
_status = AuthStatus.unauthenticated;
_errorMessage = null;
notifyListeners();
}
}
// The widget is pure UI — no business logic:
class LoginScreen extends StatelessWidget {
final AuthModel auth;
const LoginScreen({super.key, required this.auth});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: auth,
builder: (context, _) {
if (auth.status == AuthStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (auth.isAuthenticated) {
return Center(child: Text('Welcome, \${auth.email}!'));
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (auth.errorMessage != null)
Text(auth.errorMessage!, style: const TextStyle(color: Colors.red)),
ElevatedButton(
onPressed: () => auth.login('user@example.com', 'password123'),
child: const Text('Login'),
),
],
),
);
},
);
}
}
Multiple Listeners
A single ChangeNotifier can have many listeners. This is useful when multiple unrelated widgets need to respond to the same state change. Each widget registers its own listener independently.
Shopping Cart Model with Multiple Listeners
class CartModel extends ChangeNotifier {
final Map<String, int> _items = {};
Map<String, int> get items => Map.unmodifiable(_items);
int get totalItems => _items.values.fold(0, (sum, qty) => sum + qty);
bool get isEmpty => _items.isEmpty;
void addItem(String productId) {
_items[productId] = (_items[productId] ?? 0) + 1;
notifyListeners();
}
void removeItem(String productId) {
_items.remove(productId);
notifyListeners();
}
void updateQuantity(String productId, int quantity) {
if (quantity <= 0) {
_items.remove(productId);
} else {
_items[productId] = quantity;
}
notifyListeners();
}
void clear() {
_items.clear();
notifyListeners();
}
}
// Multiple widgets listen to the same CartModel:
// 1. App bar badge shows item count
class CartBadge extends StatelessWidget {
final CartModel cart;
const CartBadge({super.key, required this.cart});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: cart,
builder: (context, _) {
return Badge(
label: Text('\${cart.totalItems}'),
isLabelVisible: !cart.isEmpty,
child: const Icon(Icons.shopping_cart),
);
},
);
}
}
// 2. Checkout button enables/disables based on cart state
class CheckoutButton extends StatelessWidget {
final CartModel cart;
const CheckoutButton({super.key, required this.cart});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: cart,
builder: (context, _) {
return ElevatedButton(
onPressed: cart.isEmpty ? null : () => _checkout(),
child: Text('Checkout (\${cart.totalItems} items)'),
);
},
);
}
void _checkout() { /* Navigate to checkout */ }
}
Disposing Properly
The ChangeNotifier class has a dispose method that releases all listeners and marks the notifier as disposed. After disposal, calling notifyListeners() or addListener will throw an error. The owner of the notifier is responsible for calling dispose.
Proper Disposal Patterns
// Pattern 1: StatefulWidget owns the notifier
class MyPage extends StatefulWidget {
const MyPage({super.key});
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> {
late final TodoListModel _todoModel;
late final AuthModel _authModel;
@override
void initState() {
super.initState();
_todoModel = TodoListModel();
_authModel = AuthModel();
}
@override
void dispose() {
_todoModel.dispose();
_authModel.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
// Pattern 2: Check if disposed before notifying (defensive)
class SafeModel extends ChangeNotifier {
bool _isDisposed = false;
@override
void dispose() {
_isDisposed = true;
super.dispose();
}
@override
void notifyListeners() {
if (!_isDisposed) {
super.notifyListeners();
}
}
}
notifyListeners() in a ChangeNotifier that has been disposed. This throws a FlutterError. If your model performs async operations (like API calls), the response might arrive after disposal. Use a guard flag or check hasListeners before notifying.
ChangeNotifier models focused on a single domain. A TodoListModel should only manage todos. An AuthModel should only manage authentication state. If you find a model growing too large, split it into smaller, focused models.
Summary
ChangeNotifierimplements the Observer pattern withaddListener,removeListener, andnotifyListeners().- Call
notifyListeners()only when state actually changes to avoid unnecessary rebuilds. - Batch multiple property changes into a single
notifyListeners()call for efficiency. - Every
addListenermust have a matchingremoveListenerto prevent memory leaks. - Create dedicated model classes that encapsulate business logic, keeping widgets as thin UI layers.
- A single notifier can have many listeners — multiple widgets can react to the same state change.
- Always dispose notifiers in the owning widget’s
disposemethod, and guard against post-dispose notifications in async scenarios.