Repository: rrousselGit/flutter_hooks
Pull request #395 (2023-11-15 16:18:30)

Add hook useDebounced
Following our earlier discussion in the PR at:
https://github.com/rrousselGit/flutter_hooks/pull/394#issuecomment-1812511330

it's clear that integrating the useDebounced hook will be quite beneficial!

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/rrousselGit/flutter_hooks/pull/395?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Remi+Rousselet) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`cc0eab8`)](https://app.codecov.io/gh/rrousselGit/flutter_hooks/commit/cc0eab8fa0a63a822367a84a45fa855f5208a7f2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Remi+Rousselet) 99.87% compared to head [(`83787e3`)](https://app.codecov.io/gh/rrousselGit/flutter_hooks/pull/395?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Remi+Rousselet) 99.88%.

> :exclamation: Current head 83787e3 differs from pull request most recent head f6af0f8. Consider uploading reports for the commit f6af0f8 to get more accurate results


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master     #395   +/-   ##
=======================================
  Coverage   99.87%   99.88%           
=======================================
  Files          20       21    +1     
  Lines         830      859   +29     
=======================================
+ Hits          829      858   +29     
  Misses          1        1           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/rrousselGit/flutter_hooks/pull/395?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Remi+Rousselet).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Remi+Rousselet).

itisnajim: I believe all should be well at this point
* Using `timeout` as a positional argument instead of named.
* Adding more comments/docs.
* Removing `setValue()` as its unnecessary.
* Double checking and fixing tests.
https://github.com/rrousselGit/flutter_hooks/pull/395/commits/0d3f71f101ef818533874ef8fd19aa0720f2c218

Patch:
diff --git a/README.md b/README.md
index cdb8a10..6fb701f 100644
--- a/README.md
+++ b/README.md
@@ -356,6 +356,7 @@ A series of hooks with no particular theme.
 | [useSearchController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useSearchController.html)                     | Creates and disposes a `SearchController`.                                 |
 | [useMaterialStatesController](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useMaterialStatesController.html)     | Creates and disposes a `MaterialStatesController`.                         |
 | [useExpansionTileController](https://api.flutter.dev/flutter/material/ExpansionTileController-class.html)                            | Creates a `ExpansionTileController`.                                       |
+| [useDebounced](https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useDebounced.html)                             | Returns a debounced version of the provided value, triggering widget updates accordingly after a specified timeout duration  |
 
 ## Contributions
 
diff --git a/packages/flutter_hooks/CHANGELOG.md b/packages/flutter_hooks/CHANGELOG.md
index ad6c11f..93cc4f5 100644
--- a/packages/flutter_hooks/CHANGELOG.md
+++ b/packages/flutter_hooks/CHANGELOG.md
@@ -1,3 +1,7 @@
+## Unreleased fix
+
+- Added `useDebounced` (thanks to @itisnajim)
+
 ## 0.20.3 - 2023-10-10
 
 - Added `useExpansionTileController` (thanks to @droidbg)
diff --git a/packages/flutter_hooks/lib/src/debounced.dart b/packages/flutter_hooks/lib/src/debounced.dart
new file mode 100644
index 0000000..f93ccd4
--- /dev/null
+++ b/packages/flutter_hooks/lib/src/debounced.dart
@@ -0,0 +1,88 @@
+part of 'hooks.dart';
+
+/// Returns a debounced version of the provided value [toDebounce], triggering
+/// widget updates accordingly after a specified [timeout] duration.
+///
+/// Example:
+/// ```dart
+/// String userInput = ''; // Your input value
+///
+/// // Create a debounced version of userInput
+/// final debouncedInput = useDebounced(
+///   userInput,
+///   Duration(milliseconds: 500), // Set your desired timeout
+/// );
+/// // Assume a fetch method fetchData(String query) exists
+/// useEffect(() {
+///   fetchData(debouncedInput); // Use debouncedInput as a dependency
+///   return null;
+/// }, [debouncedInput]);
+/// ```
+T? useDebounced<T>(
+  T toDebounce,
+  Duration timeout,
+) {
+  return use(
+    _DebouncedHook(
+      toDebounce: toDebounce,
+      timeout: timeout,
+    ),
+  );
+}
+
+class _DebouncedHook<T> extends Hook<T?> {
+  const _DebouncedHook({
+    required this.toDebounce,
+    required this.timeout,
+  });
+
+  final T toDebounce;
+  final Duration timeout;
+
+  @override
+  _DebouncedHookState<T> createState() => _DebouncedHookState();
+}
+
+class _DebouncedHookState<T> extends HookState<T?, _DebouncedHook<T>> {
+  T? _state;
+  Timer? _timer;
+
+  @override
+  void initHook() {
+    super.initHook();
+    _startDebounce(hook.toDebounce);
+  }
+
+  void _startDebounce(T toDebounce) {
+    _timer?.cancel();
+    _timer = Timer(hook.timeout, () {
+      setState(() {
+        _state = toDebounce;
+      });
+    });
+  }
+
+  @override
+  void didUpdateHook(_DebouncedHook<T> oldHook) {
+    if (hook.toDebounce != oldHook.toDebounce ||
+        hook.timeout != oldHook.timeout) {
+      _startDebounce(hook.toDebounce);
+    }
+  }
+
+  @override
+  T? build(BuildContext context) => _state;
+
+  @override
+  Object? get debugValue => _state;
+
+  @override
+  String get debugLabel => 'useDebounced<$T>';
+
+  @override
+  void dispose() {
+    _timer?.cancel();
+    _timer = null;
+    super.dispose();
+  }
+}
diff --git a/packages/flutter_hooks/lib/src/hooks.dart b/packages/flutter_hooks/lib/src/hooks.dart
index 206426e..7b6cfbf 100644
--- a/packages/flutter_hooks/lib/src/hooks.dart
+++ b/packages/flutter_hooks/lib/src/hooks.dart
@@ -33,3 +33,4 @@ part 'text_controller.dart';
 part 'transformation_controller.dart';
 part 'widgets_binding_observer.dart';
 part 'material_states_controller.dart';
+part 'debounced.dart';


Test patch:
diff --git a/packages/flutter_hooks/test/use_debounce_test.dart b/packages/flutter_hooks/test/use_debounce_test.dart
new file mode 100644
index 0000000..7cd4a81
--- /dev/null
+++ b/packages/flutter_hooks/test/use_debounce_test.dart
@@ -0,0 +1,86 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('debugFillProperties', (tester) async {
+    await tester.pumpWidget(
+      HookBuilder(builder: (context) {
+        useDebounced(42, const Duration(milliseconds: 500));
+        return const SizedBox();
+      }),
+    );
+
+    // await the debouncer timeout.
+    await tester.pumpAndSettle(const Duration(milliseconds: 500));
+
+    final element = tester.element(find.byType(HookBuilder));
+
+    expect(
+      element
+          .toDiagnosticsNode(style: DiagnosticsTreeStyle.offstage)
+          .toStringDeep(),
+      equalsIgnoringHashCodes(
+        'HookBuilder\n'
+        ' │ useDebounced<int>: 42\n'
+        ' └SizedBox(renderObject: RenderConstrainedBox#00000)\n',
+      ),
+    );
+  });
+
+  group('useDebounced', () {
+    testWidgets('default value is null', (tester) async {
+      await tester.pumpWidget(
+        HookBuilder(builder: (context) {
+          final debounced = useDebounced(
+            'test',
+            const Duration(milliseconds: 500),
+          );
+          return Text(
+            debounced.toString(),
+            textDirection: TextDirection.ltr,
+          );
+        }),
+      );
+      expect(find.text('null'), findsOneWidget);
+    });
+    testWidgets('basic', (tester) async {
+      await tester.pumpWidget(
+        HookBuilder(
+          builder: (context) {
+            final textValueNotifier = useState('Hello');
+            final debounced = useDebounced(
+              textValueNotifier.value,
+              const Duration(milliseconds: 500),
+            );
+
+            useEffect(() {
+              textValueNotifier.value = 'World';
+              return null;
+            }, [textValueNotifier.value]);
+
+            return Text(
+              debounced.toString(),
+              textDirection: TextDirection.ltr,
+            );
+          },
+        ),
+      );
+
+      // Ensure that the initial value displayed is 'null'
+      expect(find.text('null'), findsOneWidget);
+
+      // Ensure that after a 500ms delay, the value 'Hello' of 'textValueNotifier'
+      // is reflected in 'debounced' and displayed
+      await tester.pumpAndSettle(const Duration(milliseconds: 500));
+      expect(find.text('Hello'), findsOneWidget);
+
+      // Ensure that after another 500ms delay, the value 'World' assigned to
+      // 'textValueNotifier' in the useEffect is reflected in 'debounced'
+      // and displayed
+      await tester.pumpAndSettle(const Duration(milliseconds: 500));
+      expect(find.text('World'), findsOneWidget);
+    });
+  });
+}