Skip to content

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:

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
}

Type Safe

All operators maintain full compile-time type checking:

dart
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); // ❌ Error

Eager 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.

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

dart
final lazy = source.map((x) => x * 2, lazy: true);
// Doesn't subscribe until addListener() is called

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

  • map() - Transform values using a function
  • select() - React only when selected property changes

Filtering

Control which values propagate through the chain:

  • where() - Filter values based on a predicate

Combining

Merge multiple ValueListenables together:

Time-Based

Control timing of value propagation:

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:

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

dart
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

dart
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

dart
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

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

dart
Widget build(BuildContext context) {
  return ValueListenableBuilder(
    valueListenable: source.map((x) => x * 2), // NEW CHAIN EVERY BUILD!
    builder: (context, value, _) => Text('$value'),
  );
}

✅ DO:

dart
// 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 →

Next Steps

Released under the MIT License.