ListNotifier
A reactive List that automatically notifies listeners when its contents change.
Overview
ListNotifier<T> is a reactive list implementation that:
- Extends the standard Dart
List<T>interface - Implements
ValueListenable<List<T>> - Automatically notifies listeners on mutations
- Supports transactions for batching operations
- Provides configurable notification modes
Basic Usage
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]
}Creating a ListNotifier
Empty List
final items = ListNotifier<String>();With Initial Data
final items = ListNotifier<String>(
data: ['item1', 'item2', 'item3'],
);With Notification Mode
final items = ListNotifier<String>(
data: ['initial'],
notificationMode: CustomNotifierMode.normal,
);With Custom Equality
class Product {
final String id;
final String name;
Product(this.id, this.name);
}
final products = ListNotifier<Product>(
notificationMode: CustomNotifierMode.normal,
customEquality: (a, b) => a.id == b.id, // Compare by ID only
);Standard List Operations
ListNotifier supports all standard List operations with automatic notifications:
Adding Elements
final items = ListNotifier<String>();
items.add('item1'); // Add single item
items.addAll(['item2', 'item3']); // Add multiple items
items.insert(0, 'first'); // Insert at index
items.insertAll(1, ['a', 'b']); // Insert multiple at indexRemoving Elements
items.remove('item1'); // Remove by value
items.removeAt(0); // Remove by index
items.removeLast(); // Remove last item
items.removeRange(0, 2); // Remove range
items.removeWhere((item) => item.startsWith('a')); // Remove conditionally
items.retainWhere((item) => item.length > 3); // Keep only matching
items.clear(); // Remove all itemsUpdating Elements
items[0] = 'updated'; // Update by index
items.setAll(0, ['a', 'b']); // Set multiple starting at index
items.setRange(0, 2, ['x', 'y']); // Replace range
items.fillRange(0, 3, 'same'); // Fill range with same valueReordering and Sorting
items.sort(); // Sort items
items.sort((a, b) => a.compareTo(b)); // Custom sort
items.shuffle(); // Randomize order
items.swap(0, 1); // Swap two elements (ListNotifier-specific)Changing Length
items.length = 10; // Grow or shrink the listSpecial ListNotifier Operations
swap()
Swap two elements by index - only notifies if elements are different:
final items = ListNotifier<int>(data: [1, 2, 3]);
items.swap(0, 2); // ✅ Notifies: [3, 2, 1]
// With normal mode and equal elements
final items2 = ListNotifier<int>(
data: [1, 1, 1],
notificationMode: CustomNotifierMode.normal,
);
items2.swap(0, 1); // ❌ No notification (elements are equal)Integration with Flutter
With ValueListenableBuilder
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
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]),
),
);
}
}Notification Modes
ListNotifier supports three notification modes:
always (Default)
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.always,
);
items.add('item'); // ✅ Notifies
items[0] = 'item'; // ✅ Notifies (even though value unchanged)
items.remove('xyz'); // ✅ Notifies (even though not in list)normal
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.normal,
);
items.add('item'); // ✅ Notifies
items[0] = 'item'; // ❌ No notification (value unchanged)
items.remove('xyz'); // ❌ No notification (not in list)manual
final items = ListNotifier<String>(
notificationMode: CustomNotifierMode.manual,
);
items.add('item1'); // No notification
items.add('item2'); // No notification
items.notifyListeners(); // ✅ Manual notificationLearn more about notification modes →
Transactions
Batch multiple operations into a single notification:
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
}Learn more about transactions →
Immutable Value
The .value getter returns an unmodifiable view:
final items = ListNotifier<String>(data: ['a', 'b', 'c']);
final immutableView = items.value;
print(immutableView); // [a, b, c]
// ❌ Throws UnsupportedError
// immutableView.add('d');
// ✅ Mutate through the notifier
items.add('d'); // Works and notifiesThis ensures all mutations go through the notification system.
Bulk Operations Behavior
ListNotifier has special behavior for bulk operations:
Append/Insert Operations
These always notify (even with empty input) in all modes except manual:
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)
items.setRange(0, 0, []); // ✅ Notifies (even though empty)Why? For performance reasons - to avoid comparing all elements. These operations are typically used for bulk loading data.
Replace Operations
These only notify if changes occurred in normal mode:
final items = ListNotifier<String>(
data: ['a', 'a', 'a'],
notificationMode: CustomNotifierMode.normal,
);
items.fillRange(0, 3, 'a'); // ❌ No notification (values unchanged)
items.fillRange(0, 3, 'b'); // ✅ Notifies (values changed)
items.replaceRange(0, 2, ['b', 'b']); // ❌ No notification (same values)
items.replaceRange(0, 2, ['c', 'd']); // ✅ Notifies (values changed)Always-Notify Operations
Some operations always trigger hasChanged flag:
shuffle()- Order changes even if values don'tsort()- Order likely changesswap()- Swapping elements (but checks equality first)setAll(),setRange()- Bulk updates
Use Cases
Todo List
class TodoListModel {
final todos = ListNotifier<Todo>();
void addTodo(String title) {
todos.add(Todo(id: generateId(), title: title, completed: false));
}
void toggleTodo(String id) {
final index = todos.indexWhere((t) => t.id == id);
if (index != -1) {
final todo = todos[index];
todos[index] = Todo(id: todo.id, title: todo.title, completed: !todo.completed);
}
}
void removeTodo(String id) {
todos.removeWhere((t) => t.id == id);
}
void reorderTodos(int oldIndex, int newIndex) {
todos.startTransAction();
final todo = todos.removeAt(oldIndex);
todos.insert(newIndex, todo);
todos.endTransAction();
}
}Chat Messages
class ChatModel {
final messages = ListNotifier<Message>();
void addMessage(Message message) {
messages.add(message);
}
void loadHistory(List<Message> history) {
messages.startTransAction();
messages.clear();
messages.addAll(history);
messages.endTransAction();
}
void deleteMessage(String messageId) {
messages.removeWhere((m) => m.id == messageId);
}
}Search Results
class SearchModel {
final results = ListNotifier<SearchResult>();
final isSearching = ValueNotifier<bool>(false);
Future<void> search(String query) async {
if (query.isEmpty) {
results.clear();
return;
}
isSearching.value = true;
try {
final newResults = await searchApi(query);
results.startTransAction();
results.clear();
results.addAll(newResults);
results.endTransAction();
} finally {
isSearching.value = false;
}
}
}Shopping Cart
class ShoppingCart {
final items = ListNotifier<CartItem>(
notificationMode: CustomNotifierMode.normal,
customEquality: (a, b) => a.productId == b.productId,
);
void addItem(Product product) {
final existingIndex = items.indexWhere((item) => item.productId == product.id);
if (existingIndex != -1) {
// Update quantity
final existing = items[existingIndex];
items[existingIndex] = CartItem(
productId: existing.productId,
name: existing.name,
quantity: existing.quantity + 1,
price: existing.price,
);
} else {
// Add new item
items.add(CartItem(
productId: product.id,
name: product.name,
quantity: 1,
price: product.price,
));
}
}
void removeItem(String productId) {
items.removeWhere((item) => item.productId == productId);
}
double get total => items.fold(0.0, (sum, item) => sum + (item.price * item.quantity));
}Performance Considerations
Memory
ListNotifier has minimal overhead compared to a regular List:
- Extends
DelegatingList(from package:collection) - Adds notification mechanism from
ChangeNotifier - Small overhead for notification mode and transaction flags
Notifications
Each mutation triggers a notification (unless in transaction or manual mode):
- Cost: O(n) where n = number of listeners
- Optimization: Use transactions for bulk operations
- Best practice: Keep listener count reasonable (< 50)
Large Lists
For very large lists (1000+ items):
- Consider pagination instead of loading all at once
- Use transactions when adding/removing many items
- Consider
normalmode if you have many no-op operations
// ❌ Bad: 1000 notifications
for (var i = 0; i < 1000; i++) {
items.add(i);
}
// ✅ Good: 1 notification
items.startTransAction();
for (var i = 0; i < 1000; i++) {
items.add(i);
}
items.endTransAction();
// ✅ Even better: addAll
items.startTransAction();
items.addAll(List.generate(1000, (i) => i));
items.endTransAction();Combining with Operators
You can chain listen_it operators on a ListNotifier:
final todos = ListNotifier<Todo>();
// React only when list length changes
final todoCount = todos.select<int>((list) => list.length);
// Filter to incomplete todos
final incompleteTodos = todos.where((list) => list.any((t) => !t.completed));
// Debounce rapid changes
final debouncedTodos = todos.debounce(Duration(milliseconds: 300));
// Use in widget
ValueListenableBuilder<int>(
valueListenable: todoCount,
builder: (context, count, _) => Text('$count todos'),
);API Reference
Constructor
ListNotifier({
List<T>? data,
CustomNotifierMode notificationMode = CustomNotifierMode.always,
bool Function(T, T)? customEquality,
})Properties
| Property | Type | Description |
|---|---|---|
value | List<T> | Unmodifiable view of current list |
length | int | Number of elements (setter triggers notification) |
first | T | First element |
last | T | Last element |
isEmpty | bool | Whether list is empty |
isNotEmpty | bool | Whether list has elements |
Methods
All standard List<T> methods plus:
| Method | Description |
|---|---|
swap(int index1, int index2) | Swap two elements |
startTransAction() | Begin transaction |
endTransAction() | End transaction and notify |
notifyListeners() | Manually notify (useful with manual mode) |
Common Pitfalls
1. Modifying the .value View
// ❌ Don't try to modify the .value getter
final view = items.value;
view.add('item'); // Throws UnsupportedError!
// ✅ Modify through the notifier
items.add('item');2. Forgetting Transactions
// ❌ Many notifications
for (final item in newItems) {
items.add(item);
}
// ✅ Single notification
items.startTransAction();
for (final item in newItems) {
items.add(item);
}
items.endTransAction();3. Nested Transactions
// ❌ Will throw assertion error
items.startTransAction();
items.add('a');
items.startTransAction(); // ERROR!
// ✅ End first transaction before starting another
items.startTransAction();
items.add('a');
items.endTransAction();
items.startTransAction();
items.add('b');
items.endTransAction();