Skip to content

Commit b84af37

Browse files
committed
feat(core): add useDebounceCallback hook
1 parent 732232a commit b84af37

File tree

6 files changed

+369
-13
lines changed

6 files changed

+369
-13
lines changed

packages/core/src/hooks/index.ts

+14-13
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
export * from './usePrevious/index.js';
2-
export * from './useMultiRef/index.js';
3-
export * from './useIsomorphicEffect/index.js';
1+
export * from './usePrevious';
2+
export * from './useMultiRef';
3+
export * from './useIsomorphicEffect';
44
export * from './useMediaQuery';
5-
export * from './useSsr/index.js';
6-
export * from './useDOMRef/index.js';
7-
export * from './useBoolean/index.js';
8-
export * from './useMutableRef/index.js';
9-
export * from './useInterval/index.js';
10-
export * from './useIsFirstRender/index.js';
11-
export * from './useEventListener/index.js';
12-
export * from './useResizeObserver/index.js';
13-
export * from './useElementSize/index.js';
14-
export * from './useRefs/index.js';
5+
export * from './useSsr';
6+
export * from './useDOMRef';
7+
export * from './useBoolean';
8+
export * from './useMutableRef';
9+
export * from './useInterval';
10+
export * from './useIsFirstRender';
11+
export * from './useEventListener';
12+
export * from './useResizeObserver';
13+
export * from './useElementSize';
14+
export * from './useRefs';
15+
export * from './useDebounceCallback';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useDebounceCallback';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Meta, Story, Status } from '../../../../../.storybook/components';
2+
3+
import * as Stories from './useDebounceCallback.stories';
4+
5+
<Meta of={Stories} />
6+
7+
# useDebounceCallback
8+
9+
<Status variant="stable" />
10+
11+
A hook that waits some time before running a function.
12+
13+
```tsx
14+
import { useDebounceCallback } from '@koobiq/react-core';
15+
```
16+
17+
When you call the returned function, it waits for the given time before running.
18+
If you call it again during that time, the previous call is canceled.
19+
The time is set in milliseconds.
20+
21+
## Definition
22+
23+
```tsx
24+
type FunctionCallback = (...args: any[]) => void;
25+
26+
export type UseDebounceCallbackReturnValue<CB extends FunctionCallback> = [
27+
CB,
28+
() => void,
29+
];
30+
31+
export type UseDebounceCallbackPropOptions = {
32+
/**
33+
* If `true`, the first call runs without delay.
34+
* @default false
35+
* */
36+
firstCallWithoutDelay: boolean;
37+
};
38+
39+
export type UseDebounceCallbackProps<CB> = {
40+
/**
41+
* The function to be debounced.
42+
* It will only run after the specified delay has passed without new calls.
43+
*/
44+
callback: CB;
45+
/**
46+
* Delay in milliseconds to wait before calling the callback.
47+
* @default 300
48+
*/
49+
delay: number;
50+
/** Additional debounce options. */
51+
options: UseDebounceCallbackPropOptions;
52+
};
53+
54+
/** A hook that waits some time before running a function. */
55+
export function useDebounceCallback<CB extends FunctionCallback>({
56+
callback,
57+
delay = 300,
58+
options = { firstCallWithoutDelay: false },
59+
}: UseDebounceCallbackProps<CB>): UseDebounceCallbackReturnValue<CB>;
60+
```
61+
62+
## Example
63+
64+
<Story of={Stories.Example} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { useState, useEffect } from 'react';
2+
3+
import { useBoolean } from '@koobiq/react-core';
4+
5+
import { useDebounceCallback } from './index';
6+
7+
export default {
8+
title: 'Hooks/useDebounceCallback',
9+
id: 'Hooks/useDebounceCallback',
10+
};
11+
12+
export const Example = () => {
13+
const [value, setValue] = useState('');
14+
const [active, setActive] = useBoolean(true);
15+
const [firstCall, setFirstCall] = useBoolean(false);
16+
const [searchString, setSearchString] = useState(value);
17+
18+
const [debouncedSetSearchString] = useDebounceCallback({
19+
callback: setSearchString,
20+
options: {
21+
firstCallWithoutDelay: firstCall,
22+
},
23+
});
24+
25+
const handler = (e: React.ChangeEvent<HTMLInputElement>) => {
26+
setValue(e.target.value);
27+
};
28+
29+
useEffect(() => {
30+
if (active) debouncedSetSearchString(value);
31+
else setSearchString(value);
32+
}, [value, active]);
33+
34+
return (
35+
<div>
36+
<section>
37+
<label>
38+
<input
39+
defaultChecked={active}
40+
type="checkbox"
41+
onClick={setActive.toggle}
42+
/>
43+
Enable delay
44+
</label>
45+
</section>
46+
<section>
47+
<label>
48+
<input
49+
type="checkbox"
50+
defaultChecked={firstCall}
51+
onClick={setFirstCall.toggle}
52+
/>
53+
Enable instant first call
54+
</label>
55+
</section>
56+
<input placeholder="Search query" value={value} onChange={handler} />
57+
<div>Query result: {searchString}</div>
58+
</div>
59+
);
60+
};
61+
62+
Example.storyName = 'Example';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
3+
4+
import { useDebounceCallback } from './index';
5+
6+
describe('useDebounceCallback', () => {
7+
const callback = vi.fn();
8+
9+
beforeEach(() => {
10+
vi.useFakeTimers();
11+
});
12+
13+
afterEach(() => {
14+
callback.mockRestore();
15+
vi.useRealTimers();
16+
});
17+
18+
describe('core logic', () => {
19+
it('should correctly delay the function call', async () => {
20+
const { result } = renderHook(() =>
21+
useDebounceCallback({ callback, delay: 300 })
22+
);
23+
24+
const [debouncedCallback] = result.current;
25+
26+
debouncedCallback();
27+
expect(callback).not.toBeCalled();
28+
29+
vi.advanceTimersByTime(250);
30+
31+
debouncedCallback();
32+
expect(callback).not.toBeCalled();
33+
34+
vi.runAllTimers();
35+
expect(callback).toHaveBeenCalledTimes(1);
36+
});
37+
38+
it('should cancel the function call when cancel is invoked', async () => {
39+
const { result } = renderHook(() =>
40+
useDebounceCallback({ callback, delay: 300 })
41+
);
42+
43+
const [debouncedCallback, cancel] = result.current;
44+
45+
debouncedCallback();
46+
cancel();
47+
48+
vi.runAllTimers();
49+
50+
expect(callback).toHaveBeenCalledTimes(0);
51+
});
52+
53+
it('should cancel the function call on unmount', async () => {
54+
const { result, unmount } = renderHook(() =>
55+
useDebounceCallback({ callback, delay: 300 })
56+
);
57+
58+
const [debouncedCallback] = result.current;
59+
60+
debouncedCallback();
61+
unmount();
62+
63+
vi.runAllTimers();
64+
65+
expect(callback).toHaveBeenCalledTimes(0);
66+
});
67+
68+
it('should reset the timeout when callback changes', async () => {
69+
const newCallback = vi.fn();
70+
71+
const { result, rerender } = renderHook(
72+
(cb: (args?: unknown) => void, delay = 300) =>
73+
useDebounceCallback({ callback: cb || callback, delay })
74+
);
75+
76+
const [debouncedCallbackOne] = result.current;
77+
debouncedCallbackOne();
78+
expect(callback).not.toBeCalled();
79+
80+
rerender(newCallback);
81+
const [debouncedCallbackTwo] = result.current;
82+
debouncedCallbackTwo();
83+
84+
vi.runAllTimers();
85+
86+
expect(callback).toHaveBeenCalledTimes(0);
87+
expect(newCallback).toHaveBeenCalledTimes(1);
88+
});
89+
90+
it('should use default delay if not provided', async () => {
91+
const { result } = renderHook(() => useDebounceCallback({ callback }));
92+
const [debouncedCallback] = result.current;
93+
94+
debouncedCallback();
95+
96+
vi.runAllTimers();
97+
98+
expect(callback).toHaveBeenCalledTimes(1);
99+
});
100+
101+
it('should correctly pass arguments to the callback', async () => {
102+
let mutableValue = false;
103+
104+
const callback = vi.fn((arg: boolean) => {
105+
mutableValue = arg;
106+
107+
return mutableValue;
108+
});
109+
110+
const { result } = renderHook(() => useDebounceCallback({ callback }));
111+
112+
const [debouncedCallback] = result.current;
113+
114+
debouncedCallback(true);
115+
116+
vi.runAllTimers();
117+
118+
expect(callback).toHaveBeenCalledTimes(1);
119+
expect(mutableValue).toBe(true);
120+
});
121+
});
122+
123+
describe('check options prop', () => {
124+
it('should call the first callback immediately when firstCallWithoutDelay is true', () => {
125+
const { result } = renderHook(() =>
126+
useDebounceCallback({
127+
callback,
128+
delay: 300,
129+
options: { firstCallWithoutDelay: true },
130+
})
131+
);
132+
133+
const [debouncedCallback] = result.current;
134+
135+
debouncedCallback();
136+
expect(callback).toHaveBeenCalledTimes(1);
137+
138+
vi.runAllTimers();
139+
140+
debouncedCallback();
141+
expect(callback).toHaveBeenCalledTimes(2);
142+
143+
debouncedCallback();
144+
expect(callback).toHaveBeenCalledTimes(2);
145+
146+
vi.runAllTimers();
147+
148+
expect(callback).toHaveBeenCalledTimes(3);
149+
});
150+
});
151+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useRef } from 'react';
4+
5+
type FunctionCallback = (...args: any[]) => void;
6+
7+
export type UseDebounceCallbackReturnValue<CB extends FunctionCallback> = [
8+
CB,
9+
() => void,
10+
];
11+
12+
export type UseDebounceCallbackPropOptions = {
13+
/**
14+
* If `true`, the first call runs without delay.
15+
* @default false
16+
* */
17+
firstCallWithoutDelay: boolean;
18+
};
19+
20+
export type UseDebounceCallbackProps<CB> = {
21+
/**
22+
* The function to be debounced.
23+
* It will only run after the specified delay has passed without new calls.
24+
*/
25+
callback: CB;
26+
/**
27+
* Delay in milliseconds to wait before calling the callback.
28+
* @default 300
29+
*/
30+
delay?: number;
31+
/** Additional debounce options. */
32+
options?: UseDebounceCallbackPropOptions;
33+
};
34+
35+
/** A hook that waits some time before running a function. */
36+
export function useDebounceCallback<CB extends FunctionCallback>({
37+
callback,
38+
delay = 300,
39+
options = { firstCallWithoutDelay: false },
40+
}: UseDebounceCallbackProps<CB>): UseDebounceCallbackReturnValue<CB> {
41+
const { firstCallWithoutDelay } = options;
42+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43+
44+
const debouncedHandler = useCallback(
45+
(...args: any[]) => {
46+
if (!timeoutRef.current && firstCallWithoutDelay) {
47+
callback(...args);
48+
49+
timeoutRef.current = setTimeout(() => {
50+
timeoutRef.current = null;
51+
}, delay);
52+
53+
return;
54+
}
55+
56+
if (timeoutRef.current) {
57+
clearTimeout(timeoutRef.current);
58+
}
59+
60+
timeoutRef.current = setTimeout(() => {
61+
callback(...args);
62+
timeoutRef.current = null;
63+
}, delay);
64+
},
65+
[callback, delay, options.firstCallWithoutDelay]
66+
) as CB;
67+
68+
const cancel = useCallback(() => {
69+
if (timeoutRef.current) {
70+
clearTimeout(timeoutRef.current);
71+
}
72+
}, []);
73+
74+
useEffect(() => () => cancel(), []);
75+
76+
return [debouncedHandler, cancel];
77+
}

0 commit comments

Comments
 (0)