Skip to content
listen_it logo

listen_it

Reactive primitives for Flutter - observable collections and powerful operators for ValueListenable.

Overview

listen_it provides two essential reactive primitives for Flutter development:

  1. Reactive Collections - ListNotifier, MapNotifier, SetNotifier that automatically notify listeners when their contents change
  2. ValueListenable Operators - Extension methods that let you transform, filter, combine, and react to value changes

These primitives work together to help you build reactive data flows in your Flutter apps without code generation or complex frameworks.

listen_it Data Flow

Installation

Add to your pubspec.yaml:

yaml
dependencies:
  listen_it: ^5.2.0

Quick Start

listen() - The Foundation

Lets you work with a ValueListenable (and Listenable) as it should be by installing a handler function that is called on any value change and gets the new value passed as an argument. This gives you the same pattern as with Streams, making it natural and consistent.

dart
// For ValueListenable<T>
ListenableSubscription listen(
  void Function(T value, ListenableSubscription subscription) handler
)

// For Listenable
ListenableSubscription listen(
  void Function(ListenableSubscription subscription) handler
)
dart
void main() {
  final listenable = ValueNotifier<int>(0);

  // Basic listen - prints every value change
  final subscription = listenable.listen((x, _) => print(x));

  listenable.value = 1; // Prints: 1
  listenable.value = 2; // Prints: 2

  // Cancel subscription when done
  subscription.cancel();

  // This won't print anything (subscription cancelled)
  listenable.value = 3;
}

The returned subscription can be used to deactivate the handler. As you might need to uninstall the handler from inside the handler you get the subscription object passed to the handler function as second parameter.

This is particularly useful when you want a handler to run only once or a certain number of times:

dart
void runOnce() {
  final listenable = ValueNotifier<int>(0);

  // Run only once
  listenable.listen((x, subscription) {
    print('First value: $x');
    subscription.cancel();
  });
}

void runNTimes() {
  final listenable = ValueNotifier<int>(0);

  // Run exactly 3 times
  var count = 0;
  listenable.listen((x, subscription) {
    print('Value: $x');
    if (++count >= 3) subscription.cancel();
  });
}

For regular Listenable (not ValueListenable), the handler only receives the subscription parameter since there's no value to access:

dart
void listenableExample() {
  final listenable = ChangeNotifier();
  listenable.listen((subscription) => print('Changed!'));
}

Why listen()?

  • Same pattern as Streams - Familiar API if you've used Stream.listen()
  • Self-cancellation - Handlers can unsubscribe themselves from inside the handler
  • Works outside the widget tree - For business logic, services, side effects
  • Multiple handlers - Install multiple independent handlers on the same Listenable

ValueListenable Operators

Chain operators together to transform and react to value changes:

dart
void main() {
  final intNotifier = ValueNotifier<int>(1);

  // Chain multiple operators together
  intNotifier
      .where((x) => x.isEven) // Only allow even numbers
      .map<String>((x) => x.toString()) // Convert to String
      .listen((s, _) => print('Result: $s'));

  intNotifier.value = 2; // Even - passes filter, converts to "2"
  // Prints: Result: 2

  intNotifier.value = 3; // Odd - blocked by filter
  // No output

  intNotifier.value = 4; // Even - passes filter, converts to "4"
  // Prints: Result: 4

  intNotifier.value = 5; // Odd - blocked by filter
  // No output

  intNotifier.value = 6; // Even - passes filter, converts to "6"
  // Prints: Result: 6
}

Available Operators

OperatorCategoryDescription
listen()ListeningInstall handlers that react to changes (Stream-like pattern)
map()TransformationTransform values to different types
select()TransformationReact only when specific properties change
where()FilteringFilter which values propagate
debounce()Time-BasedDelay notifications until changes stop
async()Time-BasedDefer updates to next frame
combineLatest()CombiningMerge 2-6 ValueListenables
mergeWith()CombiningCombine value changes from multiple sources

Reactive Collections

Reactive versions of List, Map, and Set that implement ValueListenable and automatically notify listeners on mutations:

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]
}

Use with ValueListenableBuilder for reactive UI:

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

Or with watchValue from watch_it for cleaner code:

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

CollectionUse WhenExample Use Cases
ListNotifier<T>Order matters, duplicates allowedTodo lists, chat messages, search history
MapNotifier<K,V>Need key-value lookupsUser preferences, caches, form data
SetNotifier<T>Unique items only, fast membership testsSelected item IDs, active filters, tags

When to Use What

Use ValueListenable Operators When:

  • ✅ You need to transform values (map, select)
  • ✅ You need to filter updates (where)
  • ✅ You need to debounce rapid changes (search inputs)
  • ✅ You need to combine multiple ValueListenables
  • ✅ You're building data transformation pipelines

Use Reactive Collections When:

  • ✅ You need a List, Map, or Set that notifies listeners on mutations
  • ✅ You want automatic UI updates without manual notifyListeners() calls
  • ✅ You're building reactive lists, caches, or sets in your UI layer
  • ✅ You want to batch multiple operations into a single notification

Key Concepts

Reactive Collections

All three collection types (ListNotifier, MapNotifier, SetNotifier) extend their standard Dart collection interfaces and add:

  • Automatic Notifications - Every mutation triggers listeners
  • Notification Modes - Control when notifications fire (always, normal, manual)
  • Transactions - Batch operations into single notifications
  • Immutable Values - .value getters return unmodifiable views
  • ValueListenable Interface - Works with ValueListenableBuilder and watch_it

Learn more about collections →

ValueListenable Operators

Operators create transformation chains:

  • Chainable - Each operator returns a new ValueListenable
  • Lazy Initialization - Chains subscribe only when listeners are added
  • Hot Subscription - Once subscribed, chains stay subscribed
  • Type Safe - Full compile-time type checking

Learn more about operators →

CustomValueNotifier

A ValueNotifier with configurable notification behavior and modes.

Constructor

dart
CustomValueNotifier<T>(
  T initialValue, {
  CustomNotifierMode mode = CustomNotifierMode.normal,
  bool asyncNotification = false,
  void Function(Object error, StackTrace stackTrace)? onError,
})

Parameters:

  • initialValue - The initial value
  • mode - Notification mode (default: CustomNotifierMode.normal)
  • asyncNotification - If true, notifications are deferred asynchronously to avoid setState-during-build issues
  • onError - Optional error handler called when a listener throws an exception. If not provided, exceptions are reported via FlutterError.reportError()

Basic Usage

dart
void main() {
  // Always mode - notify on every assignment
  final alwaysNotifier = CustomValueNotifier<int>(
    0,
    mode: CustomNotifierMode.always,
  );

  alwaysNotifier.addListener(() => print('Always: ${alwaysNotifier.value}'));

  alwaysNotifier.value = 42; // Notifies
  alwaysNotifier.value = 42; // Notifies again (same value)

  print('---');

  // Manual mode - only notify when you call notifyListeners()
  final manualNotifier = CustomValueNotifier<int>(
    0,
    mode: CustomNotifierMode.manual,
  );

  manualNotifier.addListener(() => print('Manual: ${manualNotifier.value}'));

  manualNotifier.value = 42; // No notification
  manualNotifier.value = 43; // No notification
  print('Current value: ${manualNotifier.value}'); // 43
  manualNotifier.notifyListeners(); // NOW listeners are notified

  print('---');

  // Normal mode (default) - notify only on value change
  final normalNotifier = CustomValueNotifier<int>(
    0,
    mode: CustomNotifierMode.normal,
  );

  normalNotifier.addListener(() => print('Normal: ${normalNotifier.value}'));

  normalNotifier.value = 42; // Notifies
  normalNotifier.value = 42; // No notification (same value)
  normalNotifier.value = 43; // Notifies
}

Notification Modes

CustomValueNotifier supports three modes via the CustomNotifierMode enum:

  • normal (default for CustomValueNotifier) - Only notifies when value actually changes using == comparison
  • always - Notifies on every assignment, even if value is the same
  • manual - Only notifies when you explicitly call notifyListeners()
dart
final counter = CustomValueNotifier<int>(
  0,
  mode: CustomNotifierMode.normal,  // default
);

counter.value = 0;  // ❌ No notification (value unchanged)
counter.value = 1;  // ✅ Notifies (value changed)

Different Defaults

CustomValueNotifier defaults to normal mode to be a drop-in replacement for ValueNotifier, which only notifies when the value actually changes using == comparison.

Reactive Collections (ListNotifier, MapNotifier, SetNotifier) default to always mode to ensure UI updates on every operation, even when objects don't override ==.

Learn more about notification modes →

Real-World Example

Combining operators and collections for reactive search:

dart
class SearchViewModel {
  final searchTerm = ValueNotifier<String>('');
  final results = ListNotifier<SearchResult>(data: []);

  SearchViewModel() {
    // Debounce search input to avoid excessive API calls
    searchTerm
        .debounce(const Duration(milliseconds: 300))
        .where((term) => term.length >= 3)
        .listen((term, _) => _performSearch(term));
  }

  Future<void> _performSearch(String term) async {
    final apiResults = await searchApi(term);

    // Use transaction to batch updates
    results.startTransAction();
    results.clear();
    results.addAll(apiResults);
    results.endTransAction();
  }
}

void main() async {
  final viewModel = SearchViewModel();

  // Listen to results
  viewModel.results.listen((items, _) {
    print('Search results: ${items.length} items');
  });

  // Simulate rapid typing
  viewModel.searchTerm.value = 'f';
  viewModel.searchTerm.value = 'fl';
  viewModel.searchTerm.value = 'flu';
  viewModel.searchTerm.value = 'flut';
  viewModel.searchTerm.value = 'flutter';

  // Only after 300ms pause, API is called and results updated
  await Future.delayed(Duration(milliseconds: 600));
}

Integration with flutter_it Ecosystem

watch_it v2.0+ provides automatic selector caching, making inline chain creation completely safe:

dart
class SafeWatchItWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // ✅ SAFE: watch_it caches selectors by default
    // Chain created ONCE on first build, reused on subsequent builds
    final value = watchValue((Model m) => m.source.map((x) => x * 2));
    return Text('$value');
  }
}

The default allowObservableChange: false caches the selector, so the chain is created only once!

Learn more about watch_it integration →

With get_it

Register your reactive collections and chains in get_it for global access:

dart
void configureDependencies() {
  getIt.registerSingleton<ListNotifier<Todo>>(ListNotifier());
  getIt.registerLazySingleton(() => ValueNotifier<String>(''));
}

Learn more about get_it →

With command_it

command_it uses listen_it operators internally for ValueListenable operations:

dart
final command = Command.createAsync<String, void>(
  (searchTerm) async => performSearch(searchTerm),
  restriction: searchTerm.where((term) => term.length >= 3),
);

Learn more about command_it →

Next Steps

Previous Package Names

  • Previously published as functional_listener (operators only)
  • Reactive collections previously published as listenable_collections
  • Both are now unified in listen_it v5.0+

Released under the MIT License.