From ec49899bacf3e5f0d81cd3a0d47abb85169f439c Mon Sep 17 00:00:00 2001 From: xobotyi Date: Thu, 17 Jun 2021 15:07:17 +0300 Subject: [PATCH] feat: new hooks `useThrottledEffect` and `useThrottledState` --- README.md | 4 ++ src/index.ts | 8 ++-- src/useThrottledCallback/__docs__/story.mdx | 4 +- .../__docs__/example.stories.tsx | 33 +++++++++++++++ src/useThrottledEffect/__docs__/story.mdx | 35 ++++++++++++++++ src/useThrottledEffect/__tests__/dom.ts | 39 ++++++++++++++++++ src/useThrottledEffect/__tests__/ssr.ts | 21 ++++++++++ src/useThrottledEffect/useThrottledEffect.ts | 25 ++++++++++++ .../__docs__/example.stories.tsx | 15 +++++++ src/useThrottledState/__docs__/story.mdx | 37 +++++++++++++++++ src/useThrottledState/__tests__/dom.ts | 40 +++++++++++++++++++ src/useThrottledState/__tests__/ssr.ts | 21 ++++++++++ src/useThrottledState/useThrottledState.ts | 21 ++++++++++ 13 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 src/useThrottledEffect/__docs__/example.stories.tsx create mode 100644 src/useThrottledEffect/__docs__/story.mdx create mode 100644 src/useThrottledEffect/__tests__/dom.ts create mode 100644 src/useThrottledEffect/__tests__/ssr.ts create mode 100644 src/useThrottledEffect/useThrottledEffect.ts create mode 100644 src/useThrottledState/__docs__/example.stories.tsx create mode 100644 src/useThrottledState/__docs__/story.mdx create mode 100644 src/useThrottledState/__tests__/dom.ts create mode 100644 src/useThrottledState/__tests__/ssr.ts create mode 100644 src/useThrottledState/useThrottledState.ts diff --git a/README.md b/README.md index a8de55da1..f42ee5105 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ import { useMountEffect } from "@react-hookz/web/esnext"; — Run effect only when component first-mounted. - [**`useRerender`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-usererender) — Return callback that re-renders component. + - [**`useThrottledEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-usethrottledeffect) + — Like `useEffect`, but passed function is throttled. - [**`useUnmountEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-useunmounteffect) — Run effect only when component unmounted. - [**`useUpdateEffect`**](https://react-hookz.github.io/web/?path=/docs/lifecycle-useupdateeffect) @@ -102,6 +104,8 @@ import { useMountEffect } from "@react-hookz/web/esnext"; — Like `useState`, but its state setter is guarded against sets on unmounted component. - [**`useToggle`**](https://react-hookz.github.io/web/?path=/docs/state-usetoggle) — Like `useState`, but can only become `true` or `false`. + - [**`useThrottledState`**](https://react-hookz.github.io/web/?path=/docs/state-usethrottledstate) + — Like `useSafeState` but its state setter is throttled. - [**`useValidator`**](https://react-hookz.github.io/web/?path=/docs/state-usevalidator) — Performs validation when any of provided dependencies has changed. diff --git a/src/index.ts b/src/index.ts index a6bc9e620..98da5ae89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,19 +12,23 @@ export { useConditionalUpdateEffect, IUseConditionalUpdateEffectPredicate, } from './useConditionalUpdateEffect/useConditionalUpdateEffect'; +export { useDebouncedEffect } from './useDebouncedEffect/useDebouncedEffect'; export { useFirstMountState } from './useFirstMountState/useFirstMountState'; export { useIsMounted } from './useIsMounted/useIsMounted'; export { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect/useIsomorphicLayoutEffect'; export { useMountEffect } from './useMountEffect/useMountEffect'; export { useRerender } from './useRerender/useRerender'; +export { useThrottledEffect } from './useThrottledEffect/useThrottledEffect'; export { useUnmountEffect } from './useUnmountEffect/useUnmountEffect'; export { useUpdateEffect } from './useUpdateEffect/useUpdateEffect'; // State +export { useDebouncedState } from './useDebouncedState/useDebouncedState'; export { useMediatedState } from './useMediatedState/useMediatedState'; export { usePrevious } from './usePrevious/usePrevious'; export { useSafeState } from './useSafeState/useSafeState'; export { useToggle } from './useToggle/useToggle'; +export { useThrottledState } from './useThrottledState/useThrottledState'; export { useValidator, IValidatorImmediate, @@ -68,7 +72,3 @@ export { useMediaQuery } from './useMediaQuery/useMediaQuery'; // Dom export { useDocumentTitle, IUseDocumentTitleOptions } from './useDocumentTitle/useDocumentTitle'; - -export { useDebouncedEffect } from './useDebouncedEffect/useDebouncedEffect'; - -export { useDebouncedState } from './useDebouncedState/useDebouncedState'; diff --git a/src/useThrottledCallback/__docs__/story.mdx b/src/useThrottledCallback/__docs__/story.mdx index e59d29b91..781a8cb9f 100644 --- a/src/useThrottledCallback/__docs__/story.mdx +++ b/src/useThrottledCallback/__docs__/story.mdx @@ -31,7 +31,9 @@ export function useThrottledCallback( ): IThrottledFunction; ``` -- **cb** _`(...args: T) => unknown`_ - function that will be throttled. +#### Arguments + +- **callback** _`(...args: T) => unknown`_ - function that will be throttled. - **deps** _`React.DependencyList`_ - dependencies list when to update callback. - **delay** _`number`_ - throttle delay. - **noTrailing** _`boolean`_ _(default: false)_ - if noTrailing is true, callback will only execute diff --git a/src/useThrottledEffect/__docs__/example.stories.tsx b/src/useThrottledEffect/__docs__/example.stories.tsx new file mode 100644 index 000000000..863c3c252 --- /dev/null +++ b/src/useThrottledEffect/__docs__/example.stories.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { useThrottledEffect } from '../..'; + +const HAS_DIGIT_REGEX = /[\d]/g; + +export const Example: React.FC = () => { + const [state, setState] = useState(''); + const [hasNumbers, setHasNumbers] = useState(false); + + useThrottledEffect( + () => { + setHasNumbers(HAS_DIGIT_REGEX.test(state)); + }, + [state], + 200 + ); + + return ( +
+
Digit check will be performed no more than one every 200ms
+
+
{hasNumbers ? 'Input has digits' : 'No digits found in input'}
+ { + setState(ev.target.value); + }} + /> +
+ ); +}; diff --git a/src/useThrottledEffect/__docs__/story.mdx b/src/useThrottledEffect/__docs__/story.mdx new file mode 100644 index 000000000..aded00760 --- /dev/null +++ b/src/useThrottledEffect/__docs__/story.mdx @@ -0,0 +1,35 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './example.stories'; + + + +# useThrottledEffect + +Like `useEffect`, but passed function is throttled. + +#### Example + + + + + +## Reference + +```ts +export function useThrottledEffect( + callback: (...args: any[]) => void, + deps: DependencyList, + delay: number, + noTrailing = false +): void; +``` + +#### Arguments + +- **callback** _`(...args: any[]) => void`_ - Callback like for `useEffect`, but without ability to +return a cleanup function. +- **deps** _`DependencyList`_ - Dependencies list that will be passed to underlying `useEffect` and +`useThrottledCallback`. +- **delay** _`number`_ - throttle delay. +- **noTrailing** _`boolean`_ _(default: false)_ - if noTrailing is true, callback will only execute +every `delay` milliseconds, otherwise, callback will be executed once, after the last call. diff --git a/src/useThrottledEffect/__tests__/dom.ts b/src/useThrottledEffect/__tests__/dom.ts new file mode 100644 index 000000000..70d8402e0 --- /dev/null +++ b/src/useThrottledEffect/__tests__/dom.ts @@ -0,0 +1,39 @@ +import { renderHook } from '@testing-library/react-hooks/dom'; +import { useThrottledEffect } from '../..'; + +describe('useThrottledEffect', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useThrottledEffect).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useThrottledEffect(() => {}, [], 200)); + expect(result.error).toBeUndefined(); + }); + + it('should throttle passed callback', () => { + const spy = jest.fn(); + const { rerender } = renderHook((dep) => useThrottledEffect(spy, [dep], 200, true), { + initialProps: 1, + }); + + expect(spy).toHaveBeenCalledTimes(1); + rerender(2); + rerender(3); + rerender(4); + expect(spy).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(200); + expect(spy).toHaveBeenCalledTimes(1); + rerender(5); + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/useThrottledEffect/__tests__/ssr.ts b/src/useThrottledEffect/__tests__/ssr.ts new file mode 100644 index 000000000..cab212842 --- /dev/null +++ b/src/useThrottledEffect/__tests__/ssr.ts @@ -0,0 +1,21 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useThrottledEffect } from '../..'; + +describe('useThrottledEffect', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useThrottledEffect).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useThrottledEffect(() => {}, [], 200)); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/src/useThrottledEffect/useThrottledEffect.ts b/src/useThrottledEffect/useThrottledEffect.ts new file mode 100644 index 000000000..afd132e29 --- /dev/null +++ b/src/useThrottledEffect/useThrottledEffect.ts @@ -0,0 +1,25 @@ +import { DependencyList, useEffect } from 'react'; +import { useThrottledCallback } from '..'; + +/** + * Like `useEffect`, but passed function is throttled. + * + * @param callback Callback like for `useEffect`, but without ability to return + * a cleanup function. + * @param deps Dependencies list that will be passed to underlying `useEffect` + * and `useThrottledCallback`. + * @param delay Throttle delay. + * @param noTrailing If noTrailing is true, callback will only execute every + * `delay` milliseconds, otherwise, callback will be executed one final time + * after the last throttled-function call. + */ +export function useThrottledEffect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (...args: any[]) => void, + deps: DependencyList, + delay: number, + noTrailing = false +): void { + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(useThrottledCallback(callback, deps, delay, noTrailing), deps); +} diff --git a/src/useThrottledState/__docs__/example.stories.tsx b/src/useThrottledState/__docs__/example.stories.tsx new file mode 100644 index 000000000..7c461bf62 --- /dev/null +++ b/src/useThrottledState/__docs__/example.stories.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { useThrottledState } from '../..'; + +export const Example: React.FC = () => { + const [state, setState] = useThrottledState('', 500); + + return ( +
+
Below state will update no more than once every 500ms
+
+
The input`s value is: {state}
+ setState(ev.target.value)} /> +
+ ); +}; diff --git a/src/useThrottledState/__docs__/story.mdx b/src/useThrottledState/__docs__/story.mdx new file mode 100644 index 000000000..6abc73e7d --- /dev/null +++ b/src/useThrottledState/__docs__/story.mdx @@ -0,0 +1,37 @@ +import {Canvas, Meta, Story} from '@storybook/addon-docs/blocks'; +import {Example} from './example.stories'; + + + +# useThrottledState + +Lise `useSafeState` but its state setter is throttled. + +#### Example + + + + + +## Reference + +```ts +export function useThrottledState( + initialState: S | (() => S), + delay: number, + noTrailing = false +): [S, Dispatch>]; +``` + +#### Arguments + +- **initialState** _`S | (() => S)`_ - Initial state to pass to underlying `useSafeState`. +- **delay** _`number`_ - Throttle delay. +- **noTrailing** _`boolean`_ _(default: false)_ - if noTrailing is true, callback will only execute +every `delay` milliseconds, otherwise, callback will be executed once, after the last call. + +#### Return + +0. **state** - current state. +1. **setState** - throttled state setter. + diff --git a/src/useThrottledState/__tests__/dom.ts b/src/useThrottledState/__tests__/dom.ts new file mode 100644 index 000000000..3aab231d2 --- /dev/null +++ b/src/useThrottledState/__tests__/dom.ts @@ -0,0 +1,40 @@ +import { renderHook, act } from '@testing-library/react-hooks/dom'; +import { useThrottledState } from '../..'; + +describe('useThrottledState', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useThrottledState).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useThrottledState('', 200)); + expect(result.error).toBeUndefined(); + }); + + it('should throttle set state', () => { + const { result } = renderHook(() => useThrottledState('', 200, true)); + + expect(result.current[0]).toBe(''); + act(() => { + result.current[1]('hello world!'); + }); + expect(result.current[0]).toBe('hello world!'); + + result.current[1]('foo'); + result.current[1]('bar'); + expect(result.current[0]).toBe('hello world!'); + jest.advanceTimersByTime(200); + act(() => { + result.current[1]('baz'); + }); + expect(result.current[0]).toBe('baz'); + }); +}); diff --git a/src/useThrottledState/__tests__/ssr.ts b/src/useThrottledState/__tests__/ssr.ts new file mode 100644 index 000000000..8648cabeb --- /dev/null +++ b/src/useThrottledState/__tests__/ssr.ts @@ -0,0 +1,21 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useThrottledState } from '../..'; + +describe('useThrottledState', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useThrottledState).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useThrottledState('', 200)); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/src/useThrottledState/useThrottledState.ts b/src/useThrottledState/useThrottledState.ts new file mode 100644 index 000000000..43d674f4c --- /dev/null +++ b/src/useThrottledState/useThrottledState.ts @@ -0,0 +1,21 @@ +import { Dispatch, SetStateAction } from 'react'; +import { useSafeState, useThrottledCallback } from '..'; + +/** + * Like `useSafeState` but its state setter is throttled. + * + * @param initialState Initial state to pass to underlying `useSafeState`. + * @param delay Throttle delay. + * @param noTrailing If noTrailing is true, callback will only execute every + * `delay` milliseconds, otherwise, callback will be executed one final time + * after the last throttled-function call. + */ +export function useThrottledState( + initialState: S | (() => S), + delay: number, + noTrailing = false +): [S, Dispatch>] { + const [state, setState] = useSafeState(initialState); + + return [state, useThrottledCallback(setState, [], delay, noTrailing)]; +}