Skip to content
ExponaBLOG
FLUTTER · DAY 027 / 100

ChangeNotifier: what every Flutter developer should know

Simple observable state.

Expona Admin8 min read

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.

dart
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.

dart
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):

dart
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:

dart
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:

dart
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:

dart
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:

dart
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:

dart
// 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:

dart
// 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
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles