Skip to content

Commit

Permalink
feat: add maxWait parameter to useDebouncedCallback hook
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed Jun 16, 2021
1 parent 2df9506 commit 8207e8a
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 25 deletions.
7 changes: 4 additions & 3 deletions src/useDebouncedCallback/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ export const Example: React.FC = () => {
(ev) => {
setState(ev.target.value);
},
500,
[]
300,
[],
500
);

return (
<div>
<div>Below state will update 500ms after last change</div>
<div>Below state will update 200ms after last change, but at least once every 500ms</div>
<br />
<div>The input`s value is: {state}</div>
<input type="text" onChange={handleChange} />
Expand Down
15 changes: 10 additions & 5 deletions src/useDebouncedCallback/__docs__/story.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ The third argument is a list of dependencies, as for `useCallback`.
> Debounced function is always a void function since original callback invoked later.
> Deferred execution automatically cancelled on component unmount.
#### Example

<Canvas>
Expand All @@ -22,13 +24,16 @@ The third argument is a list of dependencies, as for `useCallback`.
## Reference

```ts
function useDebouncedCallback<T extends unknown[]>(
cb: (...args: T) => unknown,
export function useDebouncedCallback<Args extends any[], This>(
callback: (this: This, ...args: Args) => any,
delay: number,
deps: React.DependencyList
): (...args: T) => void;
deps: DependencyList,
maxWait = 0
): IDebouncedFunction<Args, This>;
```

- **cb** _`(...args: T) => unknown`_ - function that will be debounced.
- **callback** _`(...args: T) => unknown`_ - function that will be debounced.
- **delay** _`number`_ - debounce delay.
- **deps** _`React.DependencyList`_ - dependencies list when to update callback.
- **maxWait** _`number`_ _(default: `0`)_ - Maximum amount of milliseconds that function can be
delayed before it's force execution. `0` means no max wait.
41 changes: 41 additions & 0 deletions src/useDebouncedCallback/__tests__/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,45 @@ describe('useDebouncedCallback', () => {
expect(cb1).not.toHaveBeenCalled();
expect(cb2).toHaveBeenCalledTimes(1);
});

it('should cancel debounce execution after component unmount', () => {
const cb = jest.fn();

const { result, unmount } = renderHook(() => useDebouncedCallback(cb, 150, [], 200));

result.current();
expect(cb).not.toHaveBeenCalled();
jest.advanceTimersByTime(149);
expect(cb).not.toHaveBeenCalled();
unmount();
jest.advanceTimersByTime(100);
expect(cb).not.toHaveBeenCalled();
});

it('should force execute callback after maxWait milliseconds', () => {
const cb = jest.fn();

const { result } = renderHook(() => useDebouncedCallback(cb, 150, [], 200));

result.current();
expect(cb).not.toHaveBeenCalled();
jest.advanceTimersByTime(149);
result.current();
expect(cb).not.toHaveBeenCalled();
jest.advanceTimersByTime(50);
expect(cb).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(cb).toHaveBeenCalledTimes(1);
});

it('should not execute callback twice if maxWait equals delay', () => {
const cb = jest.fn();

const { result } = renderHook(() => useDebouncedCallback(cb, 200, [], 200));

result.current();
expect(cb).not.toHaveBeenCalled();
jest.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledTimes(1);
});
});
79 changes: 62 additions & 17 deletions src/useDebouncedCallback/useDebouncedCallback.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,85 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DependencyList, useMemo, useRef } from 'react';
import { useUnmountEffect } from '../useUnmountEffect/useUnmountEffect';

export interface IDebouncedFunction<Args extends any[], This> {
(this: This, ...args: Args): void;
}

/**
* Makes passed function debounced, otherwise acts like `useCallback`.
*
* @param cb Function that will be debounced.
* @param callback Function that will be debounced.
* @param delay Debounce delay.
* @param deps Dependencies list when to update callback.
* @param maxWait Maximum amount of milliseconds that function can be delayed
* before it's force execution. 0 means no max wait.
*/
export function useDebouncedCallback<T extends (...args: any[]) => any>(
cb: T,
export function useDebouncedCallback<Args extends any[], This>(
callback: (this: This, ...args: Args) => any,
delay: number,
deps: DependencyList
): (...args: Parameters<T>) => void {
deps: DependencyList,
maxWait = 0
): IDebouncedFunction<Args, This> {
const timeout = useRef<ReturnType<typeof setTimeout>>();
const waitTimeout = useRef<ReturnType<typeof setTimeout>>();
const lastCall = useRef<{ args: Args; this: This }>();

const clear = () => {
if (timeout.current) {
clearTimeout(timeout.current);
timeout.current = undefined;
}

if (waitTimeout.current) {
clearTimeout(waitTimeout.current);
waitTimeout.current = undefined;
}
};

// cancel scheduled execution on unmount
useUnmountEffect(clear);

return useMemo(
() => {
// eslint-disable-next-line func-names
const debounced = function (...args: Parameters<T>): void {
if (timeout.current) clearTimeout(timeout.current);
const execute = () => {
// barely possible to test this line
/* istanbul ignore next */
if (!lastCall.current) return;

timeout.current = setTimeout(() => {
timeout.current = undefined;
const context = lastCall.current;
lastCall.current = undefined;

cb(...args);
}, delay);
callback.apply(context.this, context.args);

clear();
};

Object.defineProperties(debounced, {
length: { value: cb.length },
name: { value: `${cb.name || 'anonymous'}__debounced__${delay}` },
// eslint-disable-next-line func-names
const wrapped = function (this, ...args) {
if (timeout.current) {
clearTimeout(timeout.current);
}

lastCall.current = { args, this: this };

// plan regular execution
timeout.current = setTimeout(execute, delay);

// plan maxWait execution if required
if (maxWait > 0 && !waitTimeout.current) {
waitTimeout.current = setTimeout(execute, maxWait);
}
} as IDebouncedFunction<Args, This>;

Object.defineProperties(wrapped, {
length: { value: callback.length },
name: { value: `${callback.name || 'anonymous'}__debounced__${delay}` },
});

return debounced;
return wrapped;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[delay, ...deps]
[delay, maxWait, ...deps]
);
}

0 comments on commit 8207e8a

Please sign in to comment.