Notification Modes
Control when listeners are notified with three notification modes: always, normal, and manual.
Overview
All reactive collections (ListNotifier, MapNotifier, SetNotifier) support three notification modes via the CustomNotifierMode enum:
| Mode | Behavior | Use When |
|---|---|---|
| always | Notify on every operation, even if value doesn't change | Default for collections - prevents UI update confusion |
| normal | Only notify when value actually changes (using == or custom equality) | Default for CustomValueNotifier - optimizing performance |
| manual | No automatic notifications - call notifyListeners() manually | Full control over notifications |
Why always is the default for collections: Users expect the UI to rebuild when they perform an operation (like adding an item). If the operation doesn't trigger a notification, it could surprise users when the UI doesn't update as expected. The always mode ensures consistent behavior regardless of whether objects override ==.
Different Defaults
Reactive Collections (ListNotifier, MapNotifier, SetNotifier) default to always mode.
CustomValueNotifier defaults to normal mode to be a drop-in replacement for ValueNotifier, matching its behavior of only notifying when the value actually changes.
Basic Usage
void main() {
// Normal mode - only notify on actual changes
final normalCart = SetNotifier<String>(
data: {},
notificationMode: CustomNotifierMode.normal,
);
normalCart.listen((items, _) => print('Normal: $items'));
normalCart.add('item1'); // ✅ Notifies (new item)
normalCart.add('item1'); // ❌ No notification (already exists)
print('---');
// Always mode - notify on every operation (default)
final alwaysCart = SetNotifier<String>(
data: {},
notificationMode: CustomNotifierMode.always,
);
alwaysCart.listen((items, _) => print('Always: $items'));
alwaysCart.add('item1'); // ✅ Notifies
alwaysCart.add('item1'); // ✅ Notifies (even though already exists)
print('---');
// Manual mode - you control when to notify
final manualCart = SetNotifier<String>(
data: {},
notificationMode: CustomNotifierMode.manual,
);
manualCart.listen((items, _) => print('Manual: $items'));
manualCart.add('item1'); // No automatic notification
manualCart.add('item2'); // No automatic notification
manualCart.notifyListeners(); // ✅ Single notification for both adds
}always Mode (Default)
Notifies listeners on every operation, regardless of whether the value actually changed.
Why It's the Default
class User {
final String name;
final int age;
User(this.name, this.age);
// ❌ No equality override - each instance is unique
}
final users = ListNotifier<User>(); // Default: always mode
users.listen((list, _) => print('Users: ${list.length}'));
final user1 = User('John', 25);
users.add(user1); // ✅ Notifies
users.add(user1); // ✅ Notifies (duplicate reference, but UI updates)Problem with normal mode here: Without overriding ==, Dart uses reference equality. Even though it's the same object reference, users might expect the UI to update when they call .add().
Solution: Default to always mode so UI always updates when operations are performed. This matches user expectations and prevents confusion.
When to Use always
- ✅ Default choice - works correctly regardless of equality implementation
- ✅ When you want UI to update on every operation
- ✅ When objects don't override
==operator - ✅ When debugging - see every operation
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.always,
);
items.add('item'); // ✅ Notifies
items.add('item'); // ✅ Notifies (even though it's a duplicate)
items[0] = 'item'; // ✅ Notifies (even though value didn't change)normal Mode
Only notifies listeners when the value actually changes, using == comparison (or custom equality function).
Basic Usage
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.normal,
);
items.listen((list, _) => print('Changed: $list'));
items.add('item1'); // ✅ Notifies (new item)
items.add('item2'); // ✅ Notifies (new item)
items[0] = 'item1'; // ❌ No notification (same value)
items.remove('xyz'); // ❌ No notification (item not in list)With Custom Equality
Provide a custom comparison function for complex objects:
class Product {
final String id;
final String name;
final double price;
Product(this.id, this.name, this.price);
}
final products = ListNotifier<Product>(
notificationMode: CustomNotifierMode.normal,
customEquality: (a, b) => a.id == b.id, // Compare by ID only
);
final product1 = Product('1', 'Widget', 9.99);
final product2 = Product('1', 'Widget Pro', 14.99); // Same ID, different name
products.add(product1);
products[0] = product2; // ❌ No notification (same ID according to customEquality)Bulk Operations in normal Mode
Different bulk operations have different notification behavior:
Append/Insert operations - Always notify (even with empty input):
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.normal,
);
items.addAll([]); // ✅ Notifies (even though empty)
items.insertAll(0, []); // ✅ Notifies (even though empty)
items.setAll(0, []); // ✅ Notifies (even though empty)Replace operations - Only notify if changes occurred:
items.fillRange(0, 2, 'a'); // Only notifies if values changed
items.replaceRange(0, 2, []); // Only notifies if values changedWhen to Use normal
- ✅ Performance optimization - reduce unnecessary notifications
- ✅ Objects override
==operator correctly - ✅ You have custom equality logic
- ✅ No-op operations shouldn't trigger UI updates
class Todo {
final String id;
final String title;
final bool completed;
Todo(this.id, this.title, this.completed);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Todo &&
runtimeType == other.runtimeType &&
id == other.id &&
title == other.title &&
completed == other.completed;
@override
int get hashCode => id.hashCode ^ title.hashCode ^ completed.hashCode;
}
final todos = ListNotifier<Todo>(
notificationMode: CustomNotifierMode.normal,
);
final todo1 = Todo('1', 'Buy milk', false);
todos.add(todo1); // ✅ Notifies
todos[0] = todo1; // ❌ No notification (same object)
todos[0] = Todo('1', 'Buy milk', false); // ❌ No notification (equal by ==)manual Mode
No automatic notifications - you must call notifyListeners() manually.
Basic Usage
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.manual,
);
items.listen((list, _) => print('Manual notification: $list'));
items.add('item1'); // No notification
items.add('item2'); // No notification
items.add('item3'); // No notification
items.notifyListeners(); // ✅ Single notification for all 3 addsWhen to Use manual
- ✅ Complex operations requiring multiple steps
- ✅ You want explicit control over when notifications fire
- ✅ Batching operations for performance (use transactions instead!)
- ✅ Conditional notifications based on custom logic
final cart = ListNotifier<Product>(
notificationMode: CustomNotifierMode.manual,
);
void updateCart(List<Product> newProducts) {
cart.clear();
cart.addAll(newProducts);
// Only notify if cart is not empty
if (cart.isNotEmpty) {
cart.notifyListeners();
}
}manual vs Transactions
For batching operations, transactions are usually better than manual mode:
❌ With manual mode:
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.manual,
);
// Must remember to call notifyListeners()
items.add('a');
items.add('b');
items.notifyListeners(); // Easy to forget!✅ With transactions (any mode):
final items = ListNotifier<String>(); // Any mode works
items.startTransAction();
items.add('a');
items.add('b');
items.endTransAction(); // Guaranteed notificationLearn more about transactions →
Comparison Table
| Operation | always | normal | manual |
|---|---|---|---|
add(newItem) | ✅ Notifies | ✅ Notifies | ❌ No notification |
add(duplicate) (Set) | ✅ Notifies | ❌ No notification | ❌ No notification |
[index] = sameValue | ✅ Notifies | ❌ No notification | ❌ No notification |
remove(nonExistent) | ✅ Notifies | ❌ No notification | ❌ No notification |
addAll([]) (empty) | ✅ Notifies | ✅ Notifies | ❌ No notification |
fillRange() no change | ✅ Notifies | ❌ No notification | ❌ No notification |
notifyListeners() | ✅ Notifies | ✅ Notifies | ✅ Notifies |
Choosing the Right Mode
Decision Tree
Do you need full control over notifications?
├─ YES → Use manual mode
│ (But consider transactions instead!)
└─ NO → Do your objects override ==?
├─ YES → Use normal mode
│ (Reduces unnecessary notifications)
└─ NO/UNSURE → Use always mode (default)
(Prevents UI update confusion)Recommendations by Collection Type
ListNotifier:
- Default:
always- Users expect UI updates on every operation - Use
normalif: List contains value types with proper==(String, int, etc.) - Use
manualif: You have complex batch operations
MapNotifier:
- Default:
always- Safe choice for any value types - Use
normalif: You have custom key comparison or value equality - Use
manualif: You're building the map in stages
SetNotifier:
- Default:
always- Prevents confusion when adding duplicates - Use
normalif: You want no notification when adding existing items - Use
manualif: You're bulk-loading data
Real-World Examples
Example 1: Shopping Cart (normal mode)
class CartItem {
final String id;
final String name;
final int quantity;
final double price;
CartItem(this.id, this.name, this.quantity, this.price);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CartItem &&
id == other.id &&
name == other.name &&
quantity == other.quantity &&
price == other.price;
@override
int get hashCode => Object.hash(id, name, quantity, price);
}
final cart = ListNotifier<CartItem>(
notificationMode: CustomNotifierMode.normal,
);
// Only notifies when cart actually changes
void updateItemQuantity(String id, int newQuantity) {
final index = cart.indexWhere((item) => item.id == id);
if (index != -1) {
final item = cart[index];
cart[index] = CartItem(item.id, item.name, newQuantity, item.price);
// Only notifies if quantity actually changed
}
}Example 2: Selected Items (normal mode)
final selectedIds = SetNotifier<String>(
notificationMode: CustomNotifierMode.normal,
);
selectedIds.listen((ids, _) => print('Selection changed: $ids'));
selectedIds.add('item1'); // ✅ Notifies
selectedIds.add('item1'); // ❌ No notification (already in set)
selectedIds.add('item2'); // ✅ NotifiesExample 3: Form Data (manual mode)
final formData = MapNotifier<String, String>(
notificationMode: CustomNotifierMode.manual,
);
void loadFormData(Map<String, String> data) {
formData.clear();
formData.addAll(data);
// Only notify after all data is loaded
formData.notifyListeners();
}
void validateAndSubmit() {
if (isValid(formData)) {
formData.notifyListeners(); // Notify only if valid
submitForm(formData);
}
}Performance Considerations
always Mode
- Pros: Simple, predictable, prevents UI bugs
- Cons: May notify more often than necessary
- Impact: Usually negligible unless thousands of updates/second
normal Mode
- Pros: Reduces unnecessary notifications, better performance
- Cons: Requires proper
==implementation, slightly more complex - Impact: Can significantly reduce rebuilds with frequent no-op operations
manual Mode
- Pros: Maximum control, can batch multiple operations
- Cons: Easy to forget notifications, more error-prone
- Impact: Best performance when used correctly