diff --git a/README.md b/README.md index 2cd436ab..2771d83b 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,10 @@ npm i @react-hookz/web yarn add @react-hookz/web ``` -As hooks was introduced to the world in React 16.8, `@react-hookz/web` requires - you gessed it - -- `react` and `react-dom` 16.8+. - Also, as React does not support IE, `@react-hookz/web` does not do so either. You'll have to - transpile your `node-modules` in order to run in IE. +As hooks was introduced to the world in React 16.8, `@react-hookz/web` requires - you guessed it - +`react` and `react-dom` 16.8+. +Also, as React does not support IE, `@react-hookz/web` does not do so either. You'll have to +transpile your `node-modules` in order to run in IE. ## Usage @@ -50,6 +49,11 @@ import { useMountEffect } from "@react-hookz/web/esnext"; ## Hooks list +- #### Callback + + - [`useDebounceCallback`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usedebouncecallback--example) + — Makes passed function debounced, otherwise acts like `useCallback`. + - #### Lifecycle - [`useConditionalEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionaleffect--example) diff --git a/src/index.ts b/src/index.ts index 2eeceb80..9f1d621b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,4 +10,5 @@ export { useConditionalEffect } from './useConditionalEffect'; export { useConditionalUpdateEffect } from './useConditionalUpdateEffect'; export { useSafeState } from './useSafeState'; export { useMediatedState } from './useMediatedState'; +export { useDebounceCallback } from './useDebounceCallback'; export { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; diff --git a/src/useDebounceCallback.ts b/src/useDebounceCallback.ts new file mode 100644 index 00000000..e471cfc2 --- /dev/null +++ b/src/useDebounceCallback.ts @@ -0,0 +1,30 @@ +import { DependencyList, useMemo, useRef } from 'react'; + +/** + * Makes passed function debounced, otherwise acts like `useCallback`. + * + * @param cb Function that will be debounced. + * @param delay Debounce delay. + * @param deps Dependencies list when to update callback. + */ +export function useDebounceCallback( + cb: (...args: T) => unknown, + delay: number, + deps: DependencyList +): (...args: T) => void { + const timeout = useRef>(); + + return useMemo( + () => (...args: T): void => { + if (timeout.current) clearTimeout(timeout.current); + + timeout.current = setTimeout(() => { + timeout.current = undefined; + + cb(...args); + }, delay); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [delay, ...deps] + ); +} diff --git a/stories/Callback/useDebounceCallback.stories.tsx b/stories/Callback/useDebounceCallback.stories.tsx new file mode 100644 index 00000000..ae110aaf --- /dev/null +++ b/stories/Callback/useDebounceCallback.stories.tsx @@ -0,0 +1,23 @@ +import React, { useState } from 'react'; +import { useDebounceCallback } from '../../src'; + +export const Example: React.FC = () => { + const [state, setState] = useState(''); + + const handleChange: React.ChangeEventHandler = useDebounceCallback( + (ev) => { + setState(ev.target.value); + }, + 500, + [] + ); + + return ( +
+
Below state will update 500ms after last change
+
+
The input`s value is: {state}
+ +
+ ); +}; diff --git a/stories/Callback/useDebounceCallback.story.mdx b/stories/Callback/useDebounceCallback.story.mdx new file mode 100644 index 00000000..893f14d4 --- /dev/null +++ b/stories/Callback/useDebounceCallback.story.mdx @@ -0,0 +1,34 @@ +import {Canvas, Meta, Story} from "@storybook/addon-docs/blocks"; +import {Example} from "./useDebounceCallback.stories"; + + + +# useDebounceCallback + +The third argument is a list of dependencies, as for `useCallback`. + +> The third argument is dependencies list on `useEffect` manner, passed function will be re-wrapped +when delay or dependencies has changed. Changed debounce callbacks still has same timeout, meaning +that calling new debounced function will abort previously scheduled invocation. + +> Debounced function is always a void function since original callback invoked later. + +#### Example + + + + + +## Reference + +```ts +function useDebounceCallback( + cb: (...args: T) => unknown, + delay: number, + deps: React.DependencyList +): (...args: T) => void +``` + +- **cb** _`(...args: T) => unknown`_ - function that will be debounced. +- **delay** _`number`_ - debounce delay. +- **deps** _`React.DependencyList`_ - dependencies list when to update callback. diff --git a/stories/useIsomorphicLayoutEffect.story.mdx b/stories/Lifecycle/useIsomorphicLayoutEffect.story.mdx similarity index 100% rename from stories/useIsomorphicLayoutEffect.story.mdx rename to stories/Lifecycle/useIsomorphicLayoutEffect.story.mdx diff --git a/tests/dom/useDebounceCallback.test.ts b/tests/dom/useDebounceCallback.test.ts new file mode 100644 index 00000000..e3fbc88a --- /dev/null +++ b/tests/dom/useDebounceCallback.test.ts @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react-hooks/dom'; +import { useDebounceCallback } from '../../src'; + +describe('useDebounceCallback', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useDebounceCallback).toBeDefined(); + }); + + it('should render', () => { + renderHook(() => { + useDebounceCallback(() => {}, 200, []); + }); + }); + + it('should return new callback if delay is changed', () => { + const { result, rerender } = renderHook( + ({ delay }) => useDebounceCallback(() => {}, delay, []), + { + initialProps: { delay: 200 }, + } + ); + + const cb1 = result.current; + rerender({ delay: 123 }); + + expect(cb1).not.toBe(result.current); + }); + + it('should run given callback only after specified delay since last call', () => { + const cb = jest.fn(); + const { result } = renderHook(() => useDebounceCallback(cb, 200, [])); + + result.current(); + expect(cb).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(100); + result.current(); + + jest.advanceTimersByTime(199); + expect(cb).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('should pass parameters to callback', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const cb = jest.fn((_a: number, _c: string) => {}); + const { result } = renderHook(() => useDebounceCallback(cb, 200, [])); + + result.current(1, 'abc'); + jest.advanceTimersByTime(200); + expect(cb).toHaveBeenCalledWith(1, 'abc'); + }); + + it('should cancel previously scheduled call even if parameters changed', () => { + const cb1 = jest.fn(() => {}); + const cb2 = jest.fn(() => {}); + + const { result, rerender } = renderHook( + ({ i }) => useDebounceCallback(() => (i === 1 ? cb1() : cb2()), 200, [i]), + { initialProps: { i: 1 } } + ); + + result.current(); + jest.advanceTimersByTime(100); + + rerender({ i: 2 }); + result.current(); + jest.advanceTimersByTime(200); + + expect(cb1).not.toHaveBeenCalled(); + expect(cb2).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/ssr/useDebounceCallback.test.ts b/tests/ssr/useDebounceCallback.test.ts new file mode 100644 index 00000000..441c2512 --- /dev/null +++ b/tests/ssr/useDebounceCallback.test.ts @@ -0,0 +1,49 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useDebounceCallback } from '../../src'; + +describe('useDebounceCallback', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useDebounceCallback).toBeDefined(); + }); + + it('should render', () => { + renderHook(() => { + useDebounceCallback(() => {}, 200, []); + }); + }); + + it('should run given callback only after specified delay since last call', () => { + const cb = jest.fn(); + const { result } = renderHook(() => useDebounceCallback(cb, 200, [])); + + result.current(); + expect(cb).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(100); + result.current(); + + jest.advanceTimersByTime(199); + expect(cb).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('should pass parameters to callback', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const cb = jest.fn((_a: number, _c: string) => {}); + const { result } = renderHook(() => useDebounceCallback(cb, 200, [])); + + result.current(1, 'abc'); + jest.advanceTimersByTime(200); + expect(cb).toHaveBeenCalledWith(1, 'abc'); + }); +});