listen_it
Reactive primitives for Flutter - observable collections and powerful operators for ValueListenable.
Overview
listen_it provides two essential reactive primitives for Flutter development:
- Reactive Collections - ListNotifier, MapNotifier, SetNotifier that automatically notify listeners when their contents change
- 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.
Installation
Add to your pubspec.yaml:
dependencies:
listen_it: ^5.2.0Quick 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.
// For ValueListenable<T>
ListenableSubscription listen(
void Function(T value, ListenableSubscription subscription) handler
)
// For Listenable
ListenableSubscription listen(
void Function(ListenableSubscription subscription) handler
)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:
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:
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:
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
| Operator | Category | Description |
|---|---|---|
| listen() | Listening | Install handlers that react to changes (Stream-like pattern) |
| map() | Transformation | Transform values to different types |
| select() | Transformation | React only when specific properties change |
| where() | Filtering | Filter which values propagate |
| debounce() | Time-Based | Delay notifications until changes stop |
| async() | Time-Based | Defer updates to next frame |
| combineLatest() | Combining | Merge 2-6 ValueListenables |
| mergeWith() | Combining | Combine value changes from multiple sources |
Reactive Collections
Reactive versions of List, Map, and Set that implement ValueListenable and automatically notify listeners on mutations:
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:
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:
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 | Use When | Example Use Cases |
|---|---|---|
| ListNotifier<T> | Order matters, duplicates allowed | Todo lists, chat messages, search history |
| MapNotifier<K,V> | Need key-value lookups | User preferences, caches, form data |
| SetNotifier<T> | Unique items only, fast membership tests | Selected 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 -
.valuegetters return unmodifiable views - ValueListenable Interface - Works with
ValueListenableBuilderand 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
CustomValueNotifier
A ValueNotifier with configurable notification behavior and modes.
Constructor
CustomValueNotifier<T>(
T initialValue, {
CustomNotifierMode mode = CustomNotifierMode.normal,
bool asyncNotification = false,
void Function(Object error, StackTrace stackTrace)? onError,
})Parameters:
initialValue- The initial valuemode- Notification mode (default:CustomNotifierMode.normal)asyncNotification- If true, notifications are deferred asynchronously to avoid setState-during-build issuesonError- Optional error handler called when a listener throws an exception. If not provided, exceptions are reported viaFlutterError.reportError()
Basic Usage
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()
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 ==.
Real-World Example
Combining operators and collections for reactive search:
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
With watch_it (Recommended!)
watch_it v2.0+ provides automatic selector caching, making inline chain creation completely safe:
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:
void configureDependencies() {
getIt.registerSingleton<ListNotifier<Todo>>(ListNotifier());
getIt.registerLazySingleton(() => ValueNotifier<String>(''));
}With command_it
command_it uses listen_it operators internally for ValueListenable operations:
final command = Command.createAsync<String, void>(
(searchTerm) async => performSearch(searchTerm),
restriction: searchTerm.where((term) => term.length >= 3),
);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_itv5.0+