Skip to content

Commit b94ff72

Browse files
authored
feat(core/hooks): add 'useThrottledCallback' hook (#342)
2 parents b40b0ce + cec3206 commit b94ff72

7 files changed

Lines changed: 327 additions & 0 deletions

File tree

.changeset/tall-lions-rush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'react-simplikit': patch
3+
---
4+
5+
feat(core/hooks): add 'useThrottledCallback' hook
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useThrottledCallback } from './useThrottledCallback.ts';
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# useThrottledCallback
2+
3+
제공된 콜백 함수의 스로틀링된 버전을 반환하는 React 훅이에요. 스로틀링된 콜백은 지정된 간격당 최대 한 번만 호출돼요.
4+
5+
## Interface
6+
7+
```ts
8+
function useThrottledCallback<F extends (...args: any[]) => any>(
9+
callback: F,
10+
wait: number,
11+
options?: { edges?: Array<'leading' | 'trailing'> }
12+
): F & { cancel: () => void };
13+
```
14+
15+
### 파라미터
16+
17+
<Interface
18+
required
19+
name="callback"
20+
type="F"
21+
description="스로틀링할 함수예요."
22+
/>
23+
24+
<Interface
25+
required
26+
name="wait"
27+
type="number"
28+
description="호출을 스로틀링할 밀리초의 수예요."
29+
/>
30+
31+
<Interface
32+
name="options"
33+
type="{ edges?: Array<'leading' | 'trailing'> }"
34+
description="스로틀의 동작을 제어하기 위한 옵션이에요."
35+
:nested="[
36+
{
37+
name: 'options.edges',
38+
type: 'Array<\'leading\' | \'trailing\'>',
39+
required: false,
40+
defaultValue: '[\'leading\', \'trailing\']',
41+
description:
42+
'함수가 시작점, 끝점 또는 둘 다에서 호출될지 여부를 지정하는 선택적 배열이에요. <br />: 초기값은 <code>[\'leading\', \'trailing\']</code>이에요.',
43+
},
44+
]"
45+
/>
46+
47+
### 반환 값
48+
49+
<Interface
50+
name=""
51+
type="F & { cancel: () => void }"
52+
description="보류 중인 호출을 취소하는 <code>cancel</code> 메서드가 있는 스로틀링된 함수가 반환돼요."
53+
/>
54+
55+
## 예시
56+
57+
```tsx
58+
function SearchInput() {
59+
const throttledSearch = useThrottledCallback((query: string) => {
60+
console.log('검색어:', query);
61+
}, 300);
62+
63+
return <input onChange={e => throttledSearch(e.target.value)} />;
64+
}
65+
```
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# useThrottledCallback
2+
3+
`useThrottledCallback` is a React hook that returns a throttled version of the provided callback function. The throttled callback will only be invoked at most once per specified interval.
4+
5+
## Interface
6+
7+
```ts
8+
function useThrottledCallback<F extends (...args: any[]) => any>(
9+
callback: F,
10+
wait: number,
11+
options?: { edges?: Array<'leading' | 'trailing'> }
12+
): F & { cancel: () => void };
13+
```
14+
15+
### Parameters
16+
17+
<Interface
18+
required
19+
name="callback"
20+
type="F"
21+
description="The function to throttle."
22+
/>
23+
24+
<Interface
25+
required
26+
name="wait"
27+
type="number"
28+
description="The number of milliseconds to throttle invocations to."
29+
/>
30+
31+
<Interface
32+
name="options"
33+
type="{ edges?: Array<'leading' | 'trailing'> }"
34+
description="Options to control the behavior of the throttle."
35+
:nested="[
36+
{
37+
name: 'options.edges',
38+
type: 'Array<\'leading\' | \'trailing\'>',
39+
required: false,
40+
defaultValue: '[\'leading\', \'trailing\']',
41+
description:
42+
'An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. <br />: The initial value is <code>[\'leading\', \'trailing\']</code>.',
43+
},
44+
]"
45+
/>
46+
47+
### Return Value
48+
49+
<Interface
50+
name=""
51+
type="F & { cancel: () => void }"
52+
description="Returns the throttled function with a <code>cancel</code> method to cancel any pending invocation."
53+
/>
54+
55+
## Example
56+
57+
```tsx
58+
function SearchInput() {
59+
const throttledSearch = useThrottledCallback((query: string) => {
60+
console.log('Searching for:', query);
61+
}, 300);
62+
63+
return <input onChange={e => throttledSearch(e.target.value)} />;
64+
}
65+
```
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx';
4+
5+
import { useThrottledCallback } from './useThrottledCallback.ts';
6+
7+
describe('useThrottledCallback', () => {
8+
beforeEach(() => {
9+
vi.useFakeTimers();
10+
});
11+
12+
afterEach(() => {
13+
vi.useRealTimers();
14+
});
15+
16+
it('is safe on server side rendering', () => {
17+
const onChange = vi.fn();
18+
renderHookSSR.serverOnly(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
19+
20+
expect(onChange).not.toHaveBeenCalled();
21+
});
22+
23+
it('should throttle the callback with the specified time threshold', () => {
24+
const onChange = vi.fn();
25+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
26+
27+
result.current(true);
28+
expect(onChange).toBeCalledTimes(1);
29+
expect(onChange).toBeCalledWith(true);
30+
31+
result.current(true);
32+
vi.advanceTimersByTime(50);
33+
expect(onChange).toBeCalledTimes(1);
34+
35+
vi.advanceTimersByTime(50);
36+
expect(onChange).toBeCalledTimes(1);
37+
});
38+
39+
it('should call on leading edge by default', () => {
40+
const onChange = vi.fn();
41+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
42+
43+
result.current(true);
44+
expect(onChange).toBeCalledTimes(1);
45+
expect(onChange).toBeCalledWith(true);
46+
});
47+
48+
it('should handle trailing edge', () => {
49+
const onChange = vi.fn();
50+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] }));
51+
52+
result.current(true);
53+
expect(onChange).not.toBeCalled();
54+
55+
vi.advanceTimersByTime(100);
56+
expect(onChange).toBeCalledTimes(1);
57+
expect(onChange).toBeCalledWith(true);
58+
});
59+
60+
it('should not trigger callback if value has not changed', () => {
61+
const onChange = vi.fn();
62+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
63+
64+
result.current(true);
65+
vi.advanceTimersByTime(100);
66+
expect(onChange).toBeCalledTimes(1);
67+
68+
result.current(true);
69+
vi.advanceTimersByTime(100);
70+
expect(onChange).toBeCalledTimes(1);
71+
});
72+
73+
it('should cleanup on unmount', async () => {
74+
const onChange = vi.fn();
75+
const { result, unmount } = await renderHookSSR(() =>
76+
useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] })
77+
);
78+
79+
result.current(true);
80+
unmount();
81+
vi.advanceTimersByTime(100);
82+
83+
expect(onChange).not.toBeCalled();
84+
});
85+
86+
it('should handle leading and trailing edges together', () => {
87+
const onChange = vi.fn();
88+
const { result } = renderHookSSR(() =>
89+
useThrottledCallback({ onChange, timeThreshold: 100, edges: ['leading', 'trailing'] })
90+
);
91+
92+
result.current(true);
93+
expect(onChange).toBeCalledTimes(1);
94+
expect(onChange).toBeCalledWith(true);
95+
96+
vi.advanceTimersByTime(100);
97+
expect(onChange).toBeCalledTimes(1);
98+
});
99+
100+
it('should handle value toggling', () => {
101+
const onChange = vi.fn();
102+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
103+
104+
result.current(true);
105+
expect(onChange).toBeCalledTimes(1);
106+
expect(onChange).toBeCalledWith(true);
107+
108+
vi.advanceTimersByTime(100);
109+
110+
result.current(false);
111+
expect(onChange).toBeCalledTimes(2);
112+
expect(onChange).toBeCalledWith(false);
113+
});
114+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
import { usePreservedCallback } from '../usePreservedCallback/index.ts';
4+
import { usePreservedReference } from '../usePreservedReference/index.ts';
5+
import { throttle } from '../useThrottle/throttle.ts';
6+
7+
type ThrottleOptions = {
8+
edges?: Array<'leading' | 'trailing'>;
9+
};
10+
11+
/**
12+
* @description
13+
* `useThrottledCallback` is a React hook that returns a throttled version of the provided callback function.
14+
* The throttled callback will only be invoked at most once per specified interval.
15+
*
16+
* @param {Object} options - The options object.
17+
* @param {Function} options.onChange - The callback function to throttle.
18+
* @param {number} options.timeThreshold - The number of milliseconds to throttle invocations to.
19+
* @param {Array<'leading' | 'trailing'>} [options.edges=['leading', 'trailing']] - An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both.
20+
*
21+
* @returns {Function} A throttled function that limits invoking the callback.
22+
*
23+
* @example
24+
* function ScrollTracker() {
25+
* const throttledScroll = useThrottledCallback({
26+
* onChange: (scrollY: number) => console.log(scrollY),
27+
* timeThreshold: 200,
28+
* });
29+
* return <div onScroll={(e) => throttledScroll(e.currentTarget.scrollTop)} />;
30+
* }
31+
*/
32+
export function useThrottledCallback({
33+
onChange,
34+
timeThreshold,
35+
edges = ['leading', 'trailing'],
36+
}: ThrottleOptions & {
37+
onChange: (newValue: boolean) => void;
38+
timeThreshold: number;
39+
}) {
40+
const handleChange = usePreservedCallback(onChange);
41+
const ref = useRef({ value: false, clearPreviousThrottle: () => {} });
42+
43+
useEffect(function cleanupThrottleOnUnmount() {
44+
const current = ref.current;
45+
return () => {
46+
current.clearPreviousThrottle();
47+
};
48+
}, []);
49+
50+
const preservedEdges = usePreservedReference(edges);
51+
52+
return useCallback(
53+
(nextValue: boolean) => {
54+
if (nextValue === ref.current.value) {
55+
return;
56+
}
57+
58+
const throttled = throttle(
59+
() => {
60+
handleChange(nextValue);
61+
62+
ref.current.value = nextValue;
63+
},
64+
timeThreshold,
65+
{ edges: preservedEdges }
66+
);
67+
68+
ref.current.clearPreviousThrottle();
69+
70+
throttled();
71+
72+
ref.current.clearPreviousThrottle = throttled.cancel;
73+
},
74+
[handleChange, timeThreshold, preservedEdges]
75+
);
76+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export { useRefEffect } from './hooks/useRefEffect/index.ts';
2929
export { useSet } from './hooks/useSet/index.ts';
3030
export { useStorageState } from './hooks/useStorageState/index.ts';
3131
export { useThrottle } from './hooks/useThrottle/index.ts';
32+
export { useThrottledCallback } from './hooks/useThrottledCallback/index.ts';
3233
export { useTimeout } from './hooks/useTimeout/index.ts';
3334
export { useToggle } from './hooks/useToggle/index.ts';
3435
export { useVisibilityEvent } from './hooks/useVisibilityEvent/index.ts';

0 commit comments

Comments
 (0)