Skip to content

Commit

Permalink
fix: improve debounce implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
knilink committed Mar 6, 2023
1 parent 96a71b2 commit 5294ca4
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 14 deletions.
66 changes: 66 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"@mdx-js/react": "^1.6.22",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.13",
"axios": "^1.1.2",
Expand Down
12 changes: 7 additions & 5 deletions src/components/HoverDropdownMenu/index.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import _ from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import Popover from '../Popover';
import PopoverLinkItem from './PopoverLinkItem';
import { useDebounce } from '../../hooks/useDebounce';
import './styles.css';

const HoverDropdownMenu = ({ arrowPosition, headerText, hoverComponent, children }) => {
const [popperNode, setPopperNode] = React.useState(null);
const [isOpen, setIsOpen] = React.useState(false);
const [mouseInPopover, setMouseInPopover] = React.useState(false);

const closeMenu = _.debounce(() => {
setIsOpen(false);
}, 100);
const [setIsOpenDebouncily, setIsOpenImmediately] = useDebounce(setIsOpen, 100);

const closeMenu = React.useCallback(() => {
setIsOpenDebouncily(false);
}, [setIsOpenDebouncily]);

const popoverEnterHandler = React.useCallback(() => setMouseInPopover(true), [setMouseInPopover]);
const popoverLeaveHandler = React.useCallback(() => {
Expand All @@ -28,7 +30,7 @@ const HoverDropdownMenu = ({ arrowPosition, headerText, hoverComponent, children
}, [popperNode, popoverEnterHandler, popoverLeaveHandler]);

const openMenu = () => {
setIsOpen(true);
setIsOpenImmediately(true);
setMouseInPopover(false);
};

Expand Down
18 changes: 9 additions & 9 deletions src/components/Search/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import classnames from 'classnames';
import PropTypes from 'prop-types';
import Button from '../Button';
import Spinner from '../Spinner';
import { useDebounce } from '../../hooks/useDebounce';
import './styles.css';

const Search = React.forwardRef(
Expand All @@ -28,6 +29,8 @@ const Search = React.forwardRef(
) => {
const [inputValue, setInputValue] = React.useState('');

const [handleSearchDebouncily, handleSearchImmediately] = useDebounce(onSearch, debounceInterval);

const onInputChange = (event) => {
const eventValue = _.get(event, 'target.value');
if (onChange) {
Expand All @@ -36,7 +39,7 @@ const Search = React.forwardRef(
setInputValue(eventValue);
}
if (!searchOnEnter) {
onInputSearch(eventValue);
handleSearchDebouncily(eventValue);
}
};

Expand All @@ -48,26 +51,23 @@ const Search = React.forwardRef(
} else {
setInputValue('');
}
if (!searchOnEnter) onInputSearch(emptyValue);
if (!searchOnEnter) {
handleSearchImmediately(emptyValue);
}
if (onClear) onClear(emptyValue);
};

const onKeyPress = (event) => {
if (searchOnEnter && event.key === 'Enter') {
event.preventDefault();
onInputSearch(_.get(event, 'target.value'));
handleSearchImmediately(_.get(event, 'target.value'));
}
};

const onInputSearch = (searchValue) => {
const search = debounceInterval ? _.debounce(onSearch, debounceInterval) : onSearch;
search(searchValue);
};

const onSearchButtonClick = (event) => {
event.preventDefault();
const searchValue = value || inputValue;
onInputSearch(searchValue);
handleSearchImmediately(searchValue);
};

const currentInputValue = value || inputValue;
Expand Down
70 changes: 70 additions & 0 deletions src/hooks/useDebounce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React from 'react';

/**
* A custom React hook that debounces a given function and returns several methods for controlling the debounced behavior.
* @param {function} func - The function to be debounced.
* @param {number} wait - The wait time (in milliseconds) before the function is debounced.
* @returns {[function, function, function, function]} - An array containing four functions:
* - handleDebouncily: A function that will handle the debounced function call.
* - handleImmediately: A function that will immediately call the debounced function without waiting for the debounce interval.
* - flush: A function that will immediately call the debounced function and clear the debounce timer.
* - cancel: A function that will cancel the debounce timer without calling the debounced function.
*/
export const useDebounce = (func, wait) => {
const debounceRef = React.useRef({ timer: null, debounceInterval: null, func });
debounceRef.current.debounceInterval = wait;
debounceRef.current.func = func;

const handleDebouncily = React.useCallback(
(...args) => {
if (debounceRef.current.timer) {
clearTimeout(debounceRef.current.timer);
debounceRef.current.timer = null;
}
if (!debounceRef.current.debounceInterval) debounceRef.current.func(...args);
debounceRef.current.args = args;
debounceRef.current.timer = setTimeout(() => {
debounceRef.current.timer = null;
debounceRef.current.func(...args);
}, debounceRef.current.debounceInterval);
},
[debounceRef]
);

const handleImmediately = React.useCallback(
(...args) => {
if (debounceRef.current.timer) {
clearTimeout(debounceRef.current.timer);
debounceRef.current.timer = null;
}
debounceRef.current.func(...args);
},
[debounceRef]
);

const flush = React.useCallback(() => {
if (debounceRef.current.timer) {
clearTimeout(debounceRef.current.timer);
debounceRef.current.timer = null;
}
debounceRef.current.func(...debounceRef.current.args);
}, [debounceRef]);

const cancel = React.useCallback(() => {
if (debounceRef.current.timer) {
clearTimeout(debounceRef.current.timer);
debounceRef.current.timer = null;
}
}, [debounceRef]);

React.useEffect(
() => () => {
if (debounceRef.current.timer) {
clearTimeout(debounceRef.current.timer);
}
},
[debounceRef]
);

return [handleDebouncily, handleImmediately, flush, cancel];
};
97 changes: 97 additions & 0 deletions src/hooks/useDebounce.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useDebounce } from './useDebounce';

jest.useFakeTimers();

describe('useDebounce', () => {
const mockFunc = jest.fn();
const wait = 500;
const args = [1, 2, 3];

afterEach(() => {
jest.clearAllMocks();
});

it('should return an array containing four functions', () => {
const { result } = renderHook(() => useDebounce(mockFunc, wait));

expect(result.current).toHaveLength(4);
expect(result.current[0]).toBeInstanceOf(Function);
expect(result.current[1]).toBeInstanceOf(Function);
expect(result.current[2]).toBeInstanceOf(Function);
expect(result.current[3]).toBeInstanceOf(Function);
});

it('should debounce the function call using the provided wait time', () => {
const { result } = renderHook(() => useDebounce(mockFunc, wait));
const [handleDebouncily] = result.current;

act(() => {
handleDebouncily(...args);
});

// Before debounce interval
expect(mockFunc).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(wait - 1);
});

// During debounce interval
expect(mockFunc).not.toHaveBeenCalled();

act(() => {
jest.advanceTimersByTime(1);
});

// After debounce interval
expect(mockFunc).toHaveBeenCalledTimes(1);
expect(mockFunc).toHaveBeenCalledWith(...args);
});

it('should call the function immediately using handleImmediately', () => {
const { result } = renderHook(() => useDebounce(mockFunc, wait));
const [, handleImmediately] = result.current;

act(() => {
handleImmediately(...args);
});

expect(mockFunc).toHaveBeenCalledTimes(1);
expect(mockFunc).toHaveBeenCalledWith(...args);
});

it('should immediately call the function and clear the debounce timer using flush', () => {
const { result } = renderHook(() => useDebounce(mockFunc, wait));
const [handleDebouncily, , flush] = result.current;

act(() => {
handleDebouncily(...args);
flush();
});

expect(mockFunc).toHaveBeenCalledTimes(1);
expect(mockFunc).toHaveBeenCalledWith(...args);

act(() => {
jest.runOnlyPendingTimers();
});

expect(mockFunc).toHaveBeenCalledTimes(1);
});

it('should cancel the debounce timer using cancel', () => {
const { result } = renderHook(() => useDebounce(mockFunc, wait));
const [, , , cancel] = result.current;

act(() => {
cancel();
});

act(() => {
jest.runOnlyPendingTimers();
});

expect(mockFunc).not.toHaveBeenCalled();
});
});

0 comments on commit 5294ca4

Please sign in to comment.