State Management Fundamentals

ChangeNotifier Pattern

50 min Lesson 7 of 14

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();
  }
}
Key Concept: 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
  }
}
Tip: When updating multiple properties at once, set all values first and then call 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');
  }
}
Warning: Every 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();
    }
  }
}
Warning: Never call 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.
Tip: Keep your 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

  • ChangeNotifier implements the Observer pattern with addListener, removeListener, and notifyListeners().
  • Call notifyListeners() only when state actually changes to avoid unnecessary rebuilds.
  • Batch multiple property changes into a single notifyListeners() call for efficiency.
  • Every addListener must have a matching removeListener to 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 dispose method, and guard against post-dispose notifications in async scenarios.