Skip to content

Commit 453ee87

Browse files
committed
feat(core/hooks): add 'useThrottledCallback' hook
1 parent 2a901bb commit 453ee87

6 files changed

Lines changed: 305 additions & 0 deletions

File tree

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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { 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+
it('is safe on server side rendering', () => {
13+
const onChange = vi.fn();
14+
renderHookSSR.serverOnly(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
15+
16+
expect(onChange).not.toHaveBeenCalled();
17+
});
18+
19+
it('should throttle the callback with the specified time threshold', () => {
20+
const onChange = vi.fn();
21+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
22+
23+
result.current(true);
24+
expect(onChange).toBeCalledTimes(1);
25+
expect(onChange).toBeCalledWith(true);
26+
27+
result.current(true);
28+
vi.advanceTimersByTime(50);
29+
expect(onChange).toBeCalledTimes(1);
30+
31+
vi.advanceTimersByTime(50);
32+
expect(onChange).toBeCalledTimes(1);
33+
});
34+
35+
it('should call on leading edge by default', () => {
36+
const onChange = vi.fn();
37+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
38+
39+
result.current(true);
40+
expect(onChange).toBeCalledTimes(1);
41+
expect(onChange).toBeCalledWith(true);
42+
});
43+
44+
it('should handle trailing edge', () => {
45+
const onChange = vi.fn();
46+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] }));
47+
48+
result.current(true);
49+
expect(onChange).not.toBeCalled();
50+
51+
vi.advanceTimersByTime(100);
52+
expect(onChange).toBeCalledTimes(1);
53+
expect(onChange).toBeCalledWith(true);
54+
});
55+
56+
it('should not trigger callback if value has not changed', () => {
57+
const onChange = vi.fn();
58+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
59+
60+
result.current(true);
61+
vi.advanceTimersByTime(100);
62+
expect(onChange).toBeCalledTimes(1);
63+
64+
result.current(true);
65+
vi.advanceTimersByTime(100);
66+
expect(onChange).toBeCalledTimes(1);
67+
});
68+
69+
it('should cleanup on unmount', async () => {
70+
const onChange = vi.fn();
71+
const { result, unmount } = await renderHookSSR(() =>
72+
useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] })
73+
);
74+
75+
result.current(true);
76+
unmount();
77+
vi.advanceTimersByTime(100);
78+
79+
expect(onChange).not.toBeCalled();
80+
});
81+
82+
it('should handle value toggling', () => {
83+
const onChange = vi.fn();
84+
const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 }));
85+
86+
result.current(true);
87+
expect(onChange).toBeCalledTimes(1);
88+
expect(onChange).toBeCalledWith(true);
89+
90+
vi.advanceTimersByTime(100);
91+
92+
result.current(false);
93+
expect(onChange).toBeCalledTimes(2);
94+
expect(onChange).toBeCalledWith(false);
95+
});
96+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useCallback, useEffect, useMemo, useRef } from 'react';
2+
3+
import { usePreservedCallback } from '../usePreservedCallback/index.ts';
4+
import { throttle } from '../useThrottle/throttle.ts';
5+
6+
type ThrottleOptions = {
7+
edges?: Array<'leading' | 'trailing'>;
8+
};
9+
10+
/**
11+
* @description
12+
* `useThrottledCallback` is a React hook that returns a throttled version of the provided callback function.
13+
* The throttled callback will only be invoked at most once per specified interval.
14+
*
15+
* @param {Object} options - The options object.
16+
* @param {Function} options.onChange - The callback function to throttle.
17+
* @param {number} options.timeThreshold - The number of milliseconds to throttle invocations to.
18+
* @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.
19+
*
20+
* @returns {Function} A throttled function that limits invoking the callback.
21+
*
22+
* @example
23+
* function ScrollTracker() {
24+
* const throttledScroll = useThrottledCallback({
25+
* onChange: (scrollY: number) => console.log(scrollY),
26+
* timeThreshold: 200,
27+
* });
28+
* return <div onScroll={(e) => throttledScroll(e.currentTarget.scrollTop)} />;
29+
* }
30+
*/
31+
export function useThrottledCallback({
32+
onChange,
33+
timeThreshold,
34+
edges = ['leading', 'trailing'],
35+
}: ThrottleOptions & {
36+
onChange: (newValue: boolean) => void;
37+
timeThreshold: number;
38+
}) {
39+
const handleChange = usePreservedCallback(onChange);
40+
const ref = useRef({ value: false, clearPreviousThrottle: () => {} });
41+
42+
useEffect(() => {
43+
const current = ref.current;
44+
return () => {
45+
current.clearPreviousThrottle();
46+
};
47+
}, []);
48+
49+
const preservedEdges = useMemo(() => {
50+
return edges;
51+
}, [edges]);
52+
53+
return useCallback(
54+
(nextValue: boolean) => {
55+
if (nextValue === ref.current.value) {
56+
return;
57+
}
58+
59+
const throttled = throttle(
60+
() => {
61+
handleChange(nextValue);
62+
63+
ref.current.value = nextValue;
64+
},
65+
timeThreshold,
66+
{ edges: preservedEdges }
67+
);
68+
69+
ref.current.clearPreviousThrottle();
70+
71+
throttled();
72+
73+
ref.current.clearPreviousThrottle = throttled.cancel;
74+
},
75+
[handleChange, timeThreshold, preservedEdges]
76+
);
77+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export { usePrevious } from './hooks/usePrevious/index.ts';
2727
export { useRefEffect } from './hooks/useRefEffect/index.ts';
2828
export { useStorageState } from './hooks/useStorageState/index.ts';
2929
export { useThrottle } from './hooks/useThrottle/index.ts';
30+
export { useThrottledCallback } from './hooks/useThrottledCallback/index.ts';
3031
export { useTimeout } from './hooks/useTimeout/index.ts';
3132
export { useToggle } from './hooks/useToggle/index.ts';
3233
export { useVisibilityEvent } from './hooks/useVisibilityEvent/index.ts';

0 commit comments

Comments
 (0)