Skip to content

feat: add useThrottled hooks. #471

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/flutter_hooks/lib/src/hooks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ part 'scroll_controller.dart';
part 'search_controller.dart';
part 'tab_controller.dart';
part 'text_controller.dart';
part 'throttled.dart';
part 'transformation_controller.dart';
part 'tree_sliver_controller.dart';
part 'widget_states_controller.dart';
Expand Down
38 changes: 38 additions & 0 deletions packages/flutter_hooks/lib/src/throttled.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
part of 'hooks.dart';

/// widget ignore updates accordingly after a specified [duration] duration.
///
/// Example:
/// ```dart
/// String userInput = ''; // Your input value
///
/// // Create a throttle callback
/// final throttle = useThrottled(duration: const Duration(milliseconds: 500));
/// // Assume a fetch method fetchData(String query) exists
/// Button(onPressed: () => throttle(() => fetchData(userInput)));
/// ```
Comment on lines +3 to +13
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve documentation clarity and correctness.

The documentation has grammatical issues and could better explain the throttling behavior:

-/// widget ignore updates accordingly after a specified [duration] duration.
+/// Creates a throttled callback that executes at most once per [duration].
+/// 
+/// The returned function will execute the provided callback immediately on the 
+/// first call, then ignore subsequent calls until the duration has elapsed.

Also consider adding more context about the throttling behavior (leading edge vs trailing edge) to set proper expectations.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// widget ignore updates accordingly after a specified [duration] duration.
///
/// Example:
/// ```dart
/// String userInput = ''; // Your input value
///
/// // Create a throttle callback
/// final throttle = useThrottled(duration: const Duration(milliseconds: 500));
/// // Assume a fetch method fetchData(String query) exists
/// Button(onPressed: () => throttle(() => fetchData(userInput)));
/// ```
/// Creates a throttled callback that executes at most once per [duration].
///
/// The returned function will execute the provided callback immediately on the
/// first call, then ignore subsequent calls until the duration has elapsed.
///
/// Example:
///
🤖 Prompt for AI Agents
In packages/flutter_hooks/lib/src/throttled.dart around lines 3 to 13, improve
the documentation by correcting grammatical errors and clarifying the
explanation of the throttling behavior. Add details about whether the throttle
triggers on the leading edge, trailing edge, or both, to set proper expectations
for users. Ensure the example is clear and accurately demonstrates how to use
the throttle callback.

void Function(VoidCallback callback) useThrottled({
required Duration duration,
}) {
final throttler = useMemoized(() => _Throttler(duration), [duration]);
return throttler.run;
}

class _Throttler {
_Throttler(this.duration);

final Duration duration;

Timer? _timer;

bool get _isRunning => _timer != null;

void run(VoidCallback callback) {
if (!_isRunning) {
_timer = Timer(duration, () {
_timer = null;
});
callback();
}
}
Comment on lines +30 to +37
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider edge case handling for invalid durations.

The current implementation doesn't handle edge cases like Duration.zero or negative durations, which could lead to unexpected behavior.

 void run(VoidCallback callback) {
+  if (duration <= Duration.zero) {
+    callback();
+    return;
+  }
+  
   if (!_isRunning) {
     _timer = Timer(duration, () {
       _timer = null;
     });
     callback();
   }
 }

For zero or negative durations, the callback should execute immediately without throttling.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void run(VoidCallback callback) {
if (!_isRunning) {
_timer = Timer(duration, () {
_timer = null;
});
callback();
}
}
void run(VoidCallback callback) {
if (duration <= Duration.zero) {
callback();
return;
}
if (!_isRunning) {
_timer = Timer(duration, () {
_timer = null;
});
callback();
}
}
🤖 Prompt for AI Agents
In packages/flutter_hooks/lib/src/throttled.dart around lines 30 to 37, the run
method does not handle zero or negative Duration values, which can cause
unexpected behavior. Modify the method to check if the duration is zero or
negative; if so, execute the callback immediately without setting a timer or
throttling. Otherwise, proceed with the existing throttling logic.

}
Comment on lines +21 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify timer resource cleanup behavior.

The current implementation should handle timer cleanup correctly, but it's worth verifying that timers are properly disposed when the widget is unmounted.


🏁 Script executed:

#!/bin/bash
# Description: Check if there are examples of timer cleanup patterns in other hooks
# Expected: Find similar timer usage patterns and cleanup mechanisms

rg -A 10 -B 5 "Timer" --type dart packages/flutter_hooks/lib/src/

Length of output: 2978


🏁 Script executed:

#!/bin/bash
# Display _DebouncedHookState implementation and its dispose method if any
rg -A20 -B5 "class _DebouncedHookState" --type dart packages/flutter_hooks/lib/src/debounced.dart
echo "----Searching for dispose override in debounced.dart----"
rg "@override\s+void\s+dispose" --type dart packages/flutter_hooks/lib/src/debounced.dart

# Display _Throttler usage: look for HookState and dispose
rg -A20 -B5 "class _ThrottledHookState" --type dart packages/flutter_hooks/lib/src/throttled.dart || echo "No HookState class in throttled.dart"
echo "----Searching for dispose override in throttled.dart----"
rg "@override\s+void\s+dispose" --type dart packages/flutter_hooks/lib/src/throttled.dart

Length of output: 1224


🏁 Script executed:

#!/bin/bash
# Display full content of throttled.dart
sed -n '1,200p' packages/flutter_hooks/lib/src/throttled.dart

Length of output: 972


Cleanup pending timers on unmount

The _Throttler in packages/flutter_hooks/lib/src/throttled.dart schedules a Timer but never cancels it when the widget is disposed. If that timer fires after unmount, it may invoke callbacks on a disposed widget. To address this:

  • In _Throttler, add a cancel() method:
    void cancel() {
      _timer?.cancel();
      _timer = null;
    }
  • In useThrottled, register a cleanup effect:
    final throttler = useMemoized(() => _Throttler(duration), [duration]);
    useEffect(() {
      return () {
        throttler.cancel();
      };
    }, [throttler]);
    return throttler.run;
  • (Optional) Review packages/flutter_hooks/lib/src/debounced.dart—it likewise never cancels its timer on dispose and should follow a similar pattern.

These changes ensure no stray timers fire after the hook is unmounted.

🤖 Prompt for AI Agents
In packages/flutter_hooks/lib/src/throttled.dart around lines 21 to 38, the
_Throttler class creates a Timer but does not cancel it when the widget
unmounts, risking callbacks firing on disposed widgets. Fix this by adding a
cancel() method to _Throttler that cancels and nullifies the timer. Then, in the
useThrottled hook, register a cleanup effect that calls throttler.cancel() on
unmount. This ensures timers are properly cleaned up and no callbacks run after
disposal.

73 changes: 73 additions & 0 deletions packages/flutter_hooks/test/use_throttle_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('useThrottled', () {
testWidgets('no update when tapping multiple times', (tester) async {
await tester.runAsync<void>(() async {
await tester.pumpWidget(const _UseThrottledTestWidget());

final text = find.byType(GestureDetector);
expect(find.text('1'), findsOneWidget);

await tester.tap(text);
await tester.pump();

expect(find.text('2'), findsOneWidget);

await tester.tap(text);
await tester.pump();
expect(find.text('2'), findsOneWidget);

await tester.tap(text);
await tester.pump();
expect(find.text('2'), findsOneWidget);

await tester.tap(text);
await tester.pump();
expect(find.text('2'), findsOneWidget);
});
});

testWidgets('update number after duration', (tester) async {
await tester.runAsync<void>(() async {
await tester.pumpWidget(const _UseThrottledTestWidget());

final text = find.byType(GestureDetector);
expect(find.text('1'), findsOneWidget);

await tester.pumpAndSettle(_duration);
await Future<void>.delayed(_duration);

await tester.tap(text);
await tester.pump();

expect(find.text('2'), findsOneWidget);
});
});
});
}

class _UseThrottledTestWidget extends HookWidget {
const _UseThrottledTestWidget();

@override
Widget build(BuildContext context) {
final textNumber = useState(1);
final throttle = useThrottled(duration: _duration);

void updateText() {
textNumber.value++;
}

return MaterialApp(
home: GestureDetector(
onTap: () => throttle(updateText),
child: Text(textNumber.value.toString()),
),
);
}
}

const _duration = Duration(milliseconds: 500);