Operators
ValueListenable operators are extension methods that let you transform, filter, combine, and react to value changes in a reactive, composable way.
Introduction
Extension functions on ValueListenable allow you to work with them almost like synchronous streams. Each operator returns a new ValueListenable that updates when the source changes, enabling you to build complex reactive data pipelines through chaining.
Key Concepts
Chainable
Each operator (except listen()) returns a new ValueListenable, allowing you to chain multiple operators together:
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
}Type Safe
All operators maintain full compile-time type checking:
final intNotifier = ValueNotifier<int>(42);
// Type is inferred: ValueListenable<String>
final stringNotifier = intNotifier.map<String>((i) => i.toString());
// Compile error if types don't match
// final badNotifier = intNotifier.map<String>((i) => i); // ❌ ErrorEager Initialization
By default, operator chains use eager initialization - they subscribe to their source immediately, ensuring .value is always correct even before adding listeners. This fixes stale value issues but uses slightly more memory.
final source = ValueNotifier<int>(5);
final mapped = source.map((x) => x * 2); // Subscribes immediately
print(mapped.value); // Always correct: 10
source.value = 7;
print(mapped.value); // Immediately updated: 14 ✓For memory-constrained scenarios, pass lazy: true to delay subscription until the first listener is added:
final lazy = source.map((x) => x * 2, lazy: true);
// Doesn't subscribe until addListener() is calledChain Lifecycle
Once initialized (either eagerly or after first listener), operator chains maintain their subscription to the source even when they have zero listeners. This persistent subscription is by design for efficiency, but can cause memory leaks if chains are created inline in build methods.
See the best practices guide for safe patterns.
Available Operators
Transformation
Transform values to different types or select specific properties:
Filtering
Control which values propagate through the chain:
- where() - Filter values based on a predicate
Combining
Merge multiple ValueListenables together:
- combineLatest() - Combine two ValueListenables
- mergeWith() - Merge multiple ValueListenables
Time-Based
Control timing of value propagation:
- debounce() - Only propagate after a pause
- async() - Defer updates to next frame
Listening
React to value changes:
- listen() - Install a handler function that's called on every value change
Basic Usage Pattern
All operators follow a similar pattern:
final source = ValueNotifier<int>(0);
// Create operator chain
final transformed = source
.where((x) => x > 0)
.map<String>((x) => x.toString())
.debounce(Duration(milliseconds: 300));
// Use with ValueListenableBuilder
ValueListenableBuilder<String>(
valueListenable: transformed,
builder: (context, value, _) => Text(value),
);
// Or install a listener
transformed.listen((value, subscription) {
print('Value changed to: $value');
});With watch_it
watch_it v2.0+ provides automatic selector caching, making inline chain creation completely safe:
class UserInfoWidget extends WatchingWidget {
const UserInfoWidget({super.key});
@override
Widget build(BuildContext context) {
// watch_it v2.0+ caches the selector, so the chain is created only once
final userName = watchValue((Model m) =>
m.user.select<String>((u) => u.name).map((name) => name.toUpperCase()));
return Text('Hello, $userName!');
}
}The default allowObservableChange: false caches the selector, so the chain is created only once!
Learn more about watch_it integration →
Common Patterns
Transform Then Filter
final intNotifier = ValueNotifier<int>(0);
intNotifier
.map((i) => i * 2) // Double the value
.where((i) => i > 10) // Only values > 10
.listen((value, _) => print(value));Select Then Debounce
final userNotifier = ValueNotifier<User>(user);
userNotifier
.select<String>((u) => u.searchTerm) // Only when searchTerm changes
.debounce(Duration(milliseconds: 300)) // Wait for pause
.listen((term, _) => search(term));Combine Multiple Sources
final source1 = ValueNotifier<int>(0);
final source2 = ValueNotifier<String>('');
source1
.combineLatest<String, Result>(
source2,
(int i, String s) => Result(i, s),
)
.listen((result, _) => print(result));Memory Management
Important
Always create chains outside build methods or use watch_it for automatic caching.
❌ DON'T:
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: source.map((x) => x * 2), // NEW CHAIN EVERY BUILD!
builder: (context, value, _) => Text('$value'),
);
}✅ DO:
// Option 1: Create chain as field
late final chain = source.map((x) => x * 2);
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: chain, // Same object every build
builder: (context, value, _) => Text('$value'),
);
}
// Option 2: Use watch_it (automatic caching)
class MyWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final value = watchValue((Model m) => m.source.map((x) => x * 2));
return Text('$value');
}
}Read complete best practices guide →