skills/flutter-it/flutter_it/listen-it-expert

listen-it-expert

SKILL.md

listen_it Expert - ValueListenable Operators & Reactive Collections

What: Extension methods on ValueListenable/Listenable for transformations, filtering, debouncing. Plus reactive collections. Pure Dart.

CRITICAL RULES

  • Operators return NEW ValueListenable objects - MUST capture the result
  • listen() signature differs: on Listenable gets (subscription), on ValueListenable gets (value, subscription)
  • mergeWith() is an INSTANCE method on ValueListenable, NOT a static method
  • There is NO throttle() operator
  • NEVER create operator chains inline in build() - memory leak! Use class fields or watch_it
  • All operators support {bool lazy = false} for deferred initialization

listen() - Subscribing to Changes

// On ValueListenable<T> - receives value AND subscription
final sub = counter.listen((int value, ListenableSubscription subscription) {
  print('New value: $value');
  if (value > 100) subscription.cancel();  // Self-cancel
});
sub.cancel();  // Or cancel externally

// On plain Listenable - receives ONLY subscription (no value)
final sub = myChangeNotifier.listen((ListenableSubscription subscription) {
  print('Something changed');
});

Transformation Operators

All return new ValueListenable and support {bool lazy = false}:

final counter = ValueNotifier<int>(0);

// map - Transform values
final doubled = counter.map((x) => x * 2);

// select - Like map but only notifies when result CHANGES (equality check)
final user = ValueNotifier<User>(User('Alice', 30));
final name = user.select((u) => u.name);  // Only fires when name actually changes

// where - Filter values (only propagate when test passes)
final positives = counter.where((x) => x > 0);
final positives = counter.where((x) => x > 0, fallbackValue: 0);  // Default when filtered

// debounce - Delay notifications
final debouncedSearch = searchField.debounce(Duration(milliseconds: 300));

// async - Defer to next frame (prevents setState-during-build)
final deferred = counter.async();

Combining Operators

// combineLatest - Merge 2 ValueListenables
final firstName = ValueNotifier<String>('Alice');
final lastName = ValueNotifier<String>('Smith');
final fullName = firstName.combineLatest(
  lastName,
  (first, last) => '$first $last',
);

// combineLatest3/4/5/6 - Merge up to 6 sources
final combined = source1.combineLatest3(
  source2, source3,
  (v1, v2, v3) => '$v1-$v2-$v3',
);

// mergeWith - Merge multiple sources of SAME type (instance method)
final merged = source1.mergeWith([source2, source3]);
// Emits whenever any source changes, value is from the source that changed

Chaining Operators

final processed = searchInput
    .where((text) => text.isNotEmpty)
    .map((text) => text.trim().toLowerCase())
    .debounce(Duration(milliseconds: 300));

processed.listen((query, subscription) {
  performSearch(query);
});

Reactive Collections

Auto-notify listeners on mutations:

// ListNotifier
final items = ListNotifier<String>(data: ['a', 'b', 'c']);
items.add('d');           // Notifies
items.remove('a');        // Notifies
items[0] = 'z';          // Notifies
items.value;              // UnmodifiableListView (read-only access)

// MapNotifier
final settings = MapNotifier<String, dynamic>(data: {'theme': 'dark'});
settings['theme'] = 'light';   // Notifies
settings.remove('theme');      // Notifies

// SetNotifier
final tags = SetNotifier<String>(data: {'flutter', 'dart'});
tags.add('mobile');       // Notifies
tags.remove('dart');      // Notifies

Notification modes:

// CustomNotifierMode.always - notify on every operation (default for collections)
// CustomNotifierMode.normal - notify only when value changes (== check)
// CustomNotifierMode.manual - no auto-notification, call notifyListeners() yourself

final items = ListNotifier<String>(
  notificationMode: CustomNotifierMode.always,
);

Transactions (batch operations, single notification):

items.startTransAction();
items.add('a');
items.add('b');
items.add('c');
items.endTransAction();  // Single notification for all 3 adds

Anti-Patterns

// ❌ Not capturing operator result
counter.map((x) => x * 2);  // Lost! Nobody holds a reference
// ✅ Capture it
final doubled = counter.map((x) => x * 2);

// ❌ Creating chains inline in build() - MEMORY LEAK
Widget build(context) {
  final doubled = counter.map((x) => x * 2);  // New chain every build!
  ...
}
// ✅ Use class field or watch_it (caches selector automatically)
late final doubled = counter.map((x) => x * 2);  // Created once

// ❌ Using addListener instead of listen
notifier.addListener(() { print(notifier.value); });
// ✅ Use listen() from listen_it
notifier.listen((value, sub) { print(value); });

// ❌ ValueNotifier.merge (doesn't exist!)
ValueNotifier.merge([a, b], combiner);  // NOT A REAL METHOD
// ✅ Use mergeWith (instance method) or combineLatest
final merged = a.mergeWith([b]);
final combined = a.combineLatest(b, (va, vb) => va + vb);

Production Patterns

Debounced auto-save:

_dataSubscription = _data
    .debounce(const Duration(seconds: 1))
    .listen((_, __) {
  _data.saveDraft();
});

Filtered draft list:

final ListNotifier<CommonComposerData> _drafts = ListNotifier(
  notificationMode: CustomNotifierMode.manual,
);

late ValueListenable<List<CommonComposerData>> savedDrafts =
    (_drafts as ValueListenable<List<CommonComposerData>>)
        .map((list) => list.where((e) => e.intentionallySaved).toList());

Error listening on commands:

updatePostCommand.errors.listen((error, _) {
  final composerData = error!.paramData;
  composerData?.saveDraft(withIntention: true);
});

CustomValueNotifier

For advanced notification control:

final notifier = CustomValueNotifier<int>(
  0,
  mode: CustomNotifierMode.normal,      // Only notify on actual changes
  asyncNotification: false,             // true = defer to next frame
);
Weekly Installs
2
GitHub Stars
13
First Seen
2 days ago
Installed on
amp2
cline2
opencode2
cursor2
kimi-cli2
codex2