From 4756dfdf534598e37277ff7125d45855983802e8 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Wed, 5 May 2021 10:09:01 +0300 Subject: [PATCH 1/3] feat: new hook useDebounceCallback --- README.md | 14 +++-- src/index.ts | 1 + src/useDebouneCallback.ts | 30 +++++++++ stories/useDebounceCallback.stories.tsx | 23 +++++++ stories/useDebounceCallback.story.mdx | 34 ++++++++++ tests/dom/useDebounceCallback.test.ts | 83 +++++++++++++++++++++++++ tests/ssr/useDebounceCallback.test.ts | 49 +++++++++++++++ 7 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 src/useDebouneCallback.ts create mode 100644 stories/useDebounceCallback.stories.tsx create mode 100644 stories/useDebounceCallback.story.mdx create mode 100644 tests/dom/useDebounceCallback.test.ts create mode 100644 tests/ssr/useDebounceCallback.test.ts 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..fbc2ac6a 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 './useDebouneCallback'; export { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; diff --git a/src/useDebouneCallback.ts b/src/useDebouneCallback.ts new file mode 100644 index 00000000..e471cfc2 --- /dev/null +++ b/src/useDebouneCallback.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/useDebounceCallback.stories.tsx b/stories/useDebounceCallback.stories.tsx new file mode 100644 index 00000000..ec3fd2fd --- /dev/null +++ b/stories/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 within 500ms after last change
+
+
The input`s value is: {state}
+ +
+ ); +}; diff --git a/stories/useDebounceCallback.story.mdx b/stories/useDebounceCallback.story.mdx new file mode 100644 index 00000000..d91d9ee9 --- /dev/null +++ b/stories/useDebounceCallback.story.mdx @@ -0,0 +1,34 @@ +import {Canvas, Meta, Story} from "@storybook/addon-docs/blocks"; +import {Example} from "./useDebounceCallback.stories"; + + + +# useDebounceCallback + +Makes passed function debounced, otherwise acts like `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/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'); + }); +}); From 96370e7b345c5b900306f3ca914ca41ca28023d6 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Wed, 5 May 2021 16:22:22 +0300 Subject: [PATCH 2/3] fix: useDebouneCallback.ts -> useDebounceCallback.ts --- src/index.ts | 2 +- src/{useDebouneCallback.ts => useDebounceCallback.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{useDebouneCallback.ts => useDebounceCallback.ts} (100%) diff --git a/src/index.ts b/src/index.ts index fbc2ac6a..9f1d621b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,5 +10,5 @@ export { useConditionalEffect } from './useConditionalEffect'; export { useConditionalUpdateEffect } from './useConditionalUpdateEffect'; export { useSafeState } from './useSafeState'; export { useMediatedState } from './useMediatedState'; -export { useDebounceCallback } from './useDebouneCallback'; +export { useDebounceCallback } from './useDebounceCallback'; export { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; diff --git a/src/useDebouneCallback.ts b/src/useDebounceCallback.ts similarity index 100% rename from src/useDebouneCallback.ts rename to src/useDebounceCallback.ts From 993f3497c7c45f88d3c6dc4b5a8f3eb2a766c47b Mon Sep 17 00:00:00 2001 From: xobotyi Date: Thu, 6 May 2021 09:38:31 +0300 Subject: [PATCH 3/3] docs: rephrase a bit --- stories/{ => Callback}/useDebounceCallback.stories.tsx | 4 ++-- stories/{ => Callback}/useDebounceCallback.story.mdx | 2 +- stories/{ => Lifecycle}/useIsomorphicLayoutEffect.story.mdx | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename stories/{ => Callback}/useDebounceCallback.stories.tsx (78%) rename stories/{ => Callback}/useDebounceCallback.story.mdx (94%) rename stories/{ => Lifecycle}/useIsomorphicLayoutEffect.story.mdx (100%) diff --git a/stories/useDebounceCallback.stories.tsx b/stories/Callback/useDebounceCallback.stories.tsx similarity index 78% rename from stories/useDebounceCallback.stories.tsx rename to stories/Callback/useDebounceCallback.stories.tsx index ec3fd2fd..ae110aaf 100644 --- a/stories/useDebounceCallback.stories.tsx +++ b/stories/Callback/useDebounceCallback.stories.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useDebounceCallback } from '../src'; +import { useDebounceCallback } from '../../src'; export const Example: React.FC = () => { const [state, setState] = useState(''); @@ -14,7 +14,7 @@ export const Example: React.FC = () => { return (
-
Below state will update within 500ms after last change
+
Below state will update 500ms after last change

The input`s value is: {state}
diff --git a/stories/useDebounceCallback.story.mdx b/stories/Callback/useDebounceCallback.story.mdx similarity index 94% rename from stories/useDebounceCallback.story.mdx rename to stories/Callback/useDebounceCallback.story.mdx index d91d9ee9..893f14d4 100644 --- a/stories/useDebounceCallback.story.mdx +++ b/stories/Callback/useDebounceCallback.story.mdx @@ -5,7 +5,7 @@ import {Example} from "./useDebounceCallback.stories"; # useDebounceCallback -Makes passed function debounced, otherwise acts like `useCallback`. +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 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