Collections Introduction
Reactive collections automatically notify listeners when their contents change, making it easy to build reactive UIs without manual notifyListeners() calls.
What Are Reactive Collections?
listen_it provides three reactive collection types that implement ValueListenable:
- ListNotifier<T> - Reactive list with automatic notifications
- MapNotifier<K,V> - Reactive map with automatic notifications
- SetNotifier<T> - Reactive set with automatic notifications
Each collection type extends the standard Dart collection interface (List, Map, Set) and adds reactive capabilities.
Quick Example
void main() {
final items = ListNotifier<String>(data: []);
// Listen to changes - gets notified on every mutation
items.listen((list, _) {
print('List changed: $list');
});
items.add('first item');
// Prints: List changed: [first item]
items.add('second item');
// Prints: List changed: [first item, second item]
items.addAll(['third', 'fourth']);
// Prints: List changed: [first item, second item, third, fourth]
items.removeAt(1);
// Prints: List changed: [first item, third, fourth]
items[0] = 'updated first';
// Prints: List changed: [updated first, third, fourth]
}Key Features
1. Automatic Notifications
Every mutation operation automatically notifies listeners:
final items = ListNotifier<String>();
items.listen((list, _) => print('List changed: $list'));
items.add('item1'); // ✅ Notifies
items.addAll(['a', 'b']); // ✅ Notifies
items[0] = 'updated'; // ✅ Notifies
items.removeAt(0); // ✅ Notifies2. Notification Modes
Control when notifications fire with three modes:
- always (default) - Notify on every operation, even if value doesn't change
- normal - Only notify when value actually changes (using
==or custom equality) - manual - No automatic notifications, call
notifyListeners()manually
Learn why the default notifies always →
3. Transactions
Batch multiple operations into a single notification:
final items = ListNotifier<int>();
items.startTransAction();
items.add(1);
items.add(2);
items.add(3);
items.endTransAction(); // Single notification for all 3 addsLearn more about transactions →
4. Immutable Values
The .value getter returns an unmodifiable view:
final items = ListNotifier<String>(data: ['a', 'b']);
final immutableView = items.value; // UnmodifiableListView
// immutableView.add('c'); // ❌ Throws UnsupportedErrorThis ensures all mutations go through the notification system.
5. ValueListenable Interface
All collections implement ValueListenable, so they work with:
ValueListenableBuilder- Standard Flutter reactive widgetwatch_it- For cleaner reactive code- Any other state management solution that observes Listenables
- All listen_it operators - Chain transformations on collections
Use Cases
ListNotifier - Ordered Collections
Use when order matters and duplicates are allowed:
- Todo lists
- Chat message history
- Search results
- Activity feeds
- Recently viewed items
MapNotifier - Key-Value Storage
Use when you need fast lookups by key:
- User preferences
- Form data
- Caches
- Configuration settings
- ID-to-object mappings
final preferences = MapNotifier<String, dynamic>(
data: {'theme': 'dark', 'fontSize': 14},
);
preferences.listen((map, _) => savePreferences(map));
preferences['theme'] = 'light'; // ✅ NotifiesSetNotifier - Unique Collections
Use when you need unique items and fast membership tests:
- Selected item IDs
- Active filters
- Tags
- Unique categories
- User permissions
final selectedIds = SetNotifier<String>(data: {});
selectedIds.listen((set, _) => print('Selection changed: $set'));
selectedIds.add('item1'); // ✅ Notifies
selectedIds.add('item1'); // No duplicate added (Set behavior)Integration with Flutter
With ValueListenableBuilder
Standard Flutter approach:
class TodoListWidget extends StatelessWidget {
const TodoListWidget(this.todos, {super.key});
final ListNotifier<String> todos;
@override
Widget build(BuildContext context) {
// ListNotifier's value type is List<String>, not ListNotifier<String>
return ValueListenableBuilder<List<String>>(
valueListenable: todos,
builder: (context, items, _) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(
title: Text(items[index]),
),
);
},
);
}
}With watch_it (Recommended!)
Cleaner, more concise:
class TodoListWidget extends WatchingWidget {
const TodoListWidget(this.todos, {super.key});
final ListNotifier<String> todos;
@override
Widget build(BuildContext context) {
final items = watch(todos).value;
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(
title: Text(items[index]),
),
);
}
}Choosing the Right Collection
| Collection | When to Use | Example |
|---|---|---|
| ListNotifier<T> | Order matters, duplicates allowed | Todo lists, message history |
| MapNotifier<K,V> | Need key-value lookups | Settings, caches, form data |
| SetNotifier<T> | Unique items, fast membership tests | Selected IDs, filters, tags |
Common Patterns
Initialize with Data
All collections accept initial data:
final items = ListNotifier<String>(data: ['a', 'b', 'c']);
final prefs = MapNotifier<String, int>(data: {'count': 42});
final tags = SetNotifier<String>(data: {'flutter', 'dart'});Listen to Changes
Use .listen() for side effects outside the widget tree:
final cart = ListNotifier<Product>();
cart.listen((products, _) {
final total = products.fold(0.0, (sum, p) => sum + p.price);
print('Cart total: \$$total');
});Batch Operations with Transactions
Improve performance by batching updates:
void main() {
final products = ListNotifier<Product>(data: []);
// Listen to changes
products.listen((list, _) => print('Products updated: ${list.length} items'));
final product1 = Product(id: '1', name: 'Widget', price: 9.99);
final product2 = Product(id: '2', name: 'Gadget', price: 19.99);
final product3 = Product(id: '3', name: 'Doohickey', price: 29.99);
print('--- Without transaction: 3 notifications ---');
products.add(product1); // Notification 1
products.add(product2); // Notification 2
products.add(product3); // Notification 3
products.clear();
print('\n--- With transaction: 1 notification ---');
products.startTransAction();
products.add(product1); // No notification
products.add(product2); // No notification
products.add(product3); // No notification
products.endTransAction(); // Single notification for all 3 adds
}Choose Notification Mode
Default is always because users expect the UI to rebuild on every operation. Using normal mode could surprise users if the UI doesn't update when they perform an operation (like adding an item that already exists), but you can optimize with normal when you understand the trade-offs:
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.normal,
);
items.add('item1'); // ✅ Notifies
items.add('item1'); // ❌ No notification (duplicate in set/map, or no change)Why Reactive Collections?
Without Reactive Collections
class TodoList extends ValueNotifier<List<Todo>> {
TodoList() : super([]);
void addTodo(Todo todo) {
value.add(todo);
notifyListeners(); // Manual notification
}
void removeTodo(int index) {
value.removeAt(index);
notifyListeners(); // Manual notification
}
void updateTodo(int index, Todo todo) {
value[index] = todo;
notifyListeners(); // Manual notification
}
}With ListNotifier
final todos = ListNotifier<Todo>();
todos.add(todo); // ✅ Automatic notification
todos.removeAt(index); // ✅ Automatic notification
todos[index] = updatedTodo; // ✅ Automatic notificationBenefits:
- ✅ Less boilerplate
- ✅ Standard List/Map/Set APIs
- ✅ Automatic notifications
- ✅ Transaction support for batching