Repository: rrousselGit/flutter_hooks

Issue:
Trigger a callback when value listenable changes
**Is your feature request related to a problem? Please describe.**
Having a big and complicated screen, that is optimised using `useValueNotifier` instead of `useState`, often there is a need to do something when value notifier changes.
Using `useValueListenable` would trigger rebuild, which is unwanted due to rebuilds optimisation

**Describe the solution you'd like**
There is a way to add and remove listener to value notifier. This could be utilised to build a `useValueListenableChanged` hook, similar to `useValueChanged` hook that is present already.

**Describe alternatives you've considered**
`useValueChanged` hook works on a regular variable, not on `valueListenable`

**Additional context**
Here is what seemed to have worked. If there is no negative feedback I can make a PR:

```
import 'package:flutter/foundation.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

useValueListenableChanged<T>(
    ValueListenable<T> listenable, Function(T value) valueListenableChanged) {
  return use(
      _UseValueListenableChangedHook(listenable, valueListenableChanged));
}

class _UseValueListenableChangedHook<T> extends Hook {
  const _UseValueListenableChangedHook(
      this.valueListenable, this.valueListenableChanged);

  final Function(T value) valueListenableChanged;
  final ValueListenable<T> valueListenable;

  @override
  _ValueListenableChangedState createState() =>
      _ValueListenableChangedState<T>();
}

class _ValueListenableChangedState<T>
    extends HookState<dynamic, _UseValueListenableChangedHook<T>> {
  _ValueListenableChangedState();

  late VoidCallback _valueListenableChanged;

  @override
  void initHook() {
    _valueListenableChanged = () {
      hook.valueListenableChanged(hook.valueListenable.value);
    };
    hook.valueListenable.addListener(_valueListenableChanged);
  }

  @override
  void dispose() {
    hook.valueListenable.removeListener(_valueListenableChanged);
  }

  @override
  build(BuildContext context) {}

  @override
  String get debugLabel => 'useValueListenableChanged';

  @override
  Object? get debugValue => hook.valueListenable;
}

```

Feature Request: `useListener` hook
**Is your feature request related to a problem? Please describe.**

When using `Listenable` with `useEffect` it can be a bit cumbersome to always repeat the same boilerplate off `addListener` and `removeListener` again and again:

* Having to call `keys.length` times `addListener`
* Duplication of key in keys and `key.addListener(...)` for each key
* Extra level of indentation when writing the listener inside the `useEffect` closure

**Describe the solution you'd like**

Add a `useListener` hook.

```dart
void useListener(void Function() listener, List<Listenable> listenables) {
  useEffect(() {
    final listenable = Listenable.merge(listenables);
    listenable.addListener(listener);

    return () => listenable.removeListener(listener);
  }, listenables);
}
```

**Additional context**

This would be very useful to bind values when some controller changes:

```dart
final nameError = useState<String?>(null);
final nameController = useTextEditingController();

useListener(() {
  final validation = nameValidation(offlineNameController.text);
  nameError.value = validation.nameError;
}, [nameController]);
```

and even when it depends on multiple controllers like:

```dart
final activeTab = useState(_initialTab);
final primaryTabController = useTabController(
  initialLength: Tabs.primaryTabLength,
  initialIndex: activeTab.value.primaryTabIndex,
);
final secondaryTabController = useTabController(
  initialLength: Tabs.secondaryTabLength,
  initialIndex: activeTab.value.secondaryTabIndex,
);

useListener(() {
  activeTab.value = Tabs.of(
    primaryTabController.index,
    secondaryTabController.index,
  );
}, [
  primaryTabController,
  secondaryTabController,
]);
```


Discussion: 
The naming would more more "useOnListenableChange", like "useOnStreamChange"
And it would take a single listenable, not a list of listenable.

Otherwise LGTM. Feel free to make a PR if you want to :) 
Ok sure I will try and make a PR! Let me know if I mess up something doing so as it's only my second one :)

However why not a list of listenables ? It's more convenient isn't it ?
Folks wanting to listen to multiple listenables can use the hook multiple times or use Listenable.merge

It's rare that they'll want to listen to multiple listenables at once.

Fix patch:
diff --git a/README.md b/README.md
index f17108b..984f33b 100644
--- a/README.md
+++ b/README.md
@@ -331,6 +331,7 @@ They will take care of creating/updating/disposing an object.
 | [useListenableSelector](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useListenableSelector.html) | Similar to `useListenable`, but allows filtering UI rebuilds                                        |
 | [useValueNotifier](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueNotifier.html)           | Creates a `ValueNotifier` which will be automatically disposed.                                     |
 | [useValueListenable](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useValueListenable.html)       | Subscribes to a `ValueListenable` and return its value.                                             |
+| [useOnListenableChange](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useOnListenableChange.html) | Adds a given listener callback to a `Listenable` which will be automatically removed.               |
 
 #### Misc hooks:
 
diff --git a/packages/flutter_hooks/CHANGELOG.md b/packages/flutter_hooks/CHANGELOG.md
index 36b629a..1727c2a 100644
--- a/packages/flutter_hooks/CHANGELOG.md
+++ b/packages/flutter_hooks/CHANGELOG.md
@@ -1,3 +1,6 @@
+## Unreleased patch
+- Added `useOnListenableChange` (thanks to @whynotmake-it)
+
 ## 0.21.1-pre.2 - 2024-07-22
 
 - Added `onAttach` and `onDetach` to `useScrollController` and `usePageController` (thanks to @whynotmake-it)
diff --git a/packages/flutter_hooks/lib/src/listenable.dart b/packages/flutter_hooks/lib/src/listenable.dart
index 1284f2d..39ce2ed 100644
--- a/packages/flutter_hooks/lib/src/listenable.dart
+++ b/packages/flutter_hooks/lib/src/listenable.dart
@@ -128,3 +128,71 @@ class _UseValueNotifierHookState<T>
   @override
   String get debugLabel => 'useValueNotifier';
 }
+
+/// Adds a given [listener] to a [Listenable] and removes it when the hook is
+/// disposed.
+///
+/// As opposed to `useListenable`, this hook does not mark the widget as needing
+/// build when the listener is called. Use this for side effects that do not
+/// require a rebuild.
+///
+/// See also:
+///  * [Listenable]
+///  * [ValueListenable]
+///  * [useListenable]
+void useOnListenableChange(
+  Listenable? listenable,
+  VoidCallback listener,
+) {
+  return use(_OnListenableChangeHook(listenable, listener));
+}
+
+class _OnListenableChangeHook extends Hook<void> {
+  const _OnListenableChangeHook(
+    this.listenable,
+    this.listener,
+  );
+
+  final Listenable? listenable;
+  final VoidCallback listener;
+
+  @override
+  _OnListenableChangeHookState createState() => _OnListenableChangeHookState();
+}
+
+class _OnListenableChangeHookState
+    extends HookState<void, _OnListenableChangeHook> {
+  @override
+  void initHook() {
+    super.initHook();
+    hook.listenable?.addListener(_listener);
+  }
+
+  @override
+  void didUpdateHook(_OnListenableChangeHook oldHook) {
+    super.didUpdateHook(oldHook);
+    if (hook.listenable != oldHook.listenable) {
+      oldHook.listenable?.removeListener(_listener);
+      hook.listenable?.addListener(_listener);
+    }
+  }
+
+  @override
+  void build(BuildContext context) {}
+
+  @override
+  void dispose() {
+    hook.listenable?.removeListener(_listener);
+  }
+
+  /// Wraps `hook.listener` so we have a non-changing reference to it.
+  void _listener() {
+    hook.listener();
+  }
+
+  @override
+  String get debugLabel => 'useOnListenableChange';
+
+  @override
+  Object? get debugValue => hook.listenable;
+}


Test patch:
diff --git a/packages/flutter_hooks/test/use_on_listenable_change_test.dart b/packages/flutter_hooks/test/use_on_listenable_change_test.dart
new file mode 100644
index 0000000..35cc10e
--- /dev/null
+++ b/packages/flutter_hooks/test/use_on_listenable_change_test.dart
@@ -0,0 +1,165 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+
+import 'mock.dart';
+
+void main() {
+  testWidgets('debugFillProperties', (tester) async {
+    final listenable = ValueNotifier(42);
+
+    await tester.pumpWidget(
+      HookBuilder(builder: (context) {
+        useOnListenableChange(listenable, () {});
+        return const SizedBox();
+      }),
+    );
+
+    await tester.pump();
+
+    final element = tester.element(find.byType(HookBuilder));
+
+    expect(
+      element
+          .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage)
+          .toStringDeep(),
+      equalsIgnoringHashCodes(
+        'HookBuilder\n'
+        ' │ useOnListenableChange: ValueNotifier<int>#00000(42)\n'
+        ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n',
+      ),
+    );
+  });
+
+  testWidgets('calls listener when Listenable updates', (tester) async {
+    final listenable = ValueNotifier(42);
+
+    int? value;
+
+    await tester.pumpWidget(
+      HookBuilder(builder: (context) {
+        useOnListenableChange(
+          listenable,
+          () => value = listenable.value,
+        );
+        return const SizedBox();
+      }),
+    );
+
+    expect(value, isNull);
+    listenable.value++;
+    expect(value, 43);
+  });
+
+  testWidgets(
+    'listens new Listenable when Listenable is changed',
+    (tester) async {
+      final listenable1 = ValueNotifier(42);
+      final listenable2 = ValueNotifier(42);
+
+      await tester.pumpWidget(
+        HookBuilder(
+          builder: (context) {
+            useOnListenableChange(listenable1, () {});
+            return const SizedBox();
+          },
+        ),
+      );
+
+      await tester.pumpWidget(
+        HookBuilder(
+          builder: (context) {
+            useOnListenableChange(listenable2, () {});
+            return const SizedBox();
+          },
+        ),
+      );
+
+      // ignore: invalid_use_of_protected_member
+      expect(listenable1.hasListeners, isFalse);
+      // ignore: invalid_use_of_protected_member
+      expect(listenable2.hasListeners, isTrue);
+    },
+  );
+
+  testWidgets(
+    'listens new listener when listener is changed',
+    (tester) async {
+      final listenable = ValueNotifier(42);
+      late final int value;
+
+      void listener1() {
+        throw StateError('listener1 should not have been called');
+      }
+
+      void listener2() {
+        value = listenable.value;
+      }
+
+      await tester.pumpWidget(
+        HookBuilder(
+          builder: (context) {
+            useOnListenableChange(listenable, listener1);
+            return const SizedBox();
+          },
+        ),
+      );
+
+      await tester.pumpWidget(
+        HookBuilder(
+          builder: (context) {
+            useOnListenableChange(listenable, listener2);
+            return const SizedBox();
+          },
+        ),
+      );
+
+      listenable.value++;
+      // By now, we should have subscribed to listener2, which sets the value
+      expect(value, 43);
+    },
+  );
+
+  testWidgets('unsubscribes when listenable becomes null', (tester) async {
+    final listenable = ValueNotifier(42);
+
+    await tester.pumpWidget(
+      HookBuilder(builder: (context) {
+        useOnListenableChange(listenable, () {});
+        return const SizedBox();
+      }),
+    );
+
+    // ignore: invalid_use_of_protected_member
+    expect(listenable.hasListeners, isTrue);
+
+    await tester.pumpWidget(
+      HookBuilder(builder: (context) {
+        useOnListenableChange(null, () {});
+        return const SizedBox();
+      }),
+    );
+
+    // ignore: invalid_use_of_protected_member
+    expect(listenable.hasListeners, isFalse);
+  });
+
+  testWidgets('unsubscribes when disposed', (tester) async {
+    final listenable = ValueNotifier(42);
+
+    await tester.pumpWidget(
+      HookBuilder(builder: (context) {
+        useOnListenableChange(listenable, () {});
+        return const SizedBox();
+      }),
+    );
+
+    // ignore: invalid_use_of_protected_member
+    expect(listenable.hasListeners, isTrue);
+
+    await tester.pumpWidget(Container());
+
+    // ignore: invalid_use_of_protected_member
+    expect(listenable.hasListeners, isFalse);
+  });
+}