Skip to content

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:

Each collection type extends the standard Dart collection interface (List, Map, Set) and adds reactive capabilities.

Quick Example

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

dart
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);         // ✅ Notifies

2. 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:

dart
final items = ListNotifier<int>();

items.startTransAction();
items.add(1);
items.add(2);
items.add(3);
items.endTransAction();  // Single notification for all 3 adds

Learn more about transactions →

4. Immutable Values

The .value getter returns an unmodifiable view:

dart
final items = ListNotifier<String>(data: ['a', 'b']);

final immutableView = items.value;  // UnmodifiableListView
// immutableView.add('c');  // ❌ Throws UnsupportedError

This ensures all mutations go through the notification system.

5. ValueListenable Interface

All collections implement ValueListenable, so they work with:

  • ValueListenableBuilder - Standard Flutter reactive widget
  • watch_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
dart
final preferences = MapNotifier<String, dynamic>(
  data: {'theme': 'dark', 'fontSize': 14},
);

preferences.listen((map, _) => savePreferences(map));

preferences['theme'] = 'light';  // ✅ Notifies

SetNotifier - Unique Collections

Use when you need unique items and fast membership tests:

  • Selected item IDs
  • Active filters
  • Tags
  • Unique categories
  • User permissions
dart
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:

dart
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]),
          ),
        );
      },
    );
  }
}

Cleaner, more concise:

dart
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

CollectionWhen to UseExample
ListNotifier<T>Order matters, duplicates allowedTodo lists, message history
MapNotifier<K,V>Need key-value lookupsSettings, caches, form data
SetNotifier<T>Unique items, fast membership testsSelected IDs, filters, tags

Common Patterns

Initialize with Data

All collections accept initial data:

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

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

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

dart
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

dart
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

dart
final todos = ListNotifier<Todo>();

todos.add(todo);           // ✅ Automatic notification
todos.removeAt(index);     // ✅ Automatic notification
todos[index] = updatedTodo; // ✅ Automatic notification

Benefits:

  • ✅ Less boilerplate
  • ✅ Standard List/Map/Set APIs
  • ✅ Automatic notifications
  • ✅ Transaction support for batching

Next Steps

Released under the MIT License.