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); // βοΈ ErrorChain Lifecycle β
Operator chains subscribe to their source when they are created, detach when their last listener is removed and re-attach on the next listener. While detached, .value is derived from the current source value on read, so it is never stale:
final source = ValueNotifier<int>(5);
final mapped = source.map((x) => x * 2);
print(mapped.value); // 10
source.value = 7;
print(mapped.value); // 14 β
- correct with or without listenersPass lazy: true to delay the first subscription until the first listener is added - a pure memory optimisation, .value stays correct:
final lazy = source.map((x) => x * 2, lazy: true);
print(lazy.value); // derived on read, no subscription yetKeep Transformations Pure
The transformation may run on read while the chain is detached, so transformation functions must not have side effects.
See the best practices guide for the full lifecycle.
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 caches the selector, so creating a chain inline in a selector is safe - it is created once per widget instance and detaches from its source when the widget is disposed:
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!');
}
}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
Create chains once - outside build methods or inside a watch_it selector. A chain created in build() is a new object on every rebuild; it detaches once its listeners are gone, so it doesn't leak, but it does needless work.
βοΈ 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 β