diff --git a/README.md b/README.md index a8de55da..f42ee510 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 a6bc9e62..98da5ae8 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/useDebouncedState/__docs__/example.stories.tsx b/src/useDebouncedState/__docs__/example.stories.tsx index d32b7614..891162e6 100644 --- a/src/useDebouncedState/__docs__/example.stories.tsx +++ b/src/useDebouncedState/__docs__/example.stories.tsx @@ -6,7 +6,7 @@ export const Example: React.FC = () => { return (
-
Below state will update 200ms after last change, but at least once every 500ms
+
Below state will update 300ms after last change, but at least once every 500ms

The input`s value is: {state}
( ): 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 +- **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/useThrottledCallback/useThrottledCallback.ts b/src/useThrottledCallback/useThrottledCallback.ts index d015ae94..3410cc83 100644 --- a/src/useThrottledCallback/useThrottledCallback.ts +++ b/src/useThrottledCallback/useThrottledCallback.ts @@ -12,7 +12,7 @@ export interface IThrottledFunction { * @param callback Function that will be throttled. * @param deps Dependencies list when to update callback. * @param delay Throttle delay. - * @param noTrailing If noTrailing is true, callback will only execute every + * @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. */ diff --git a/src/useThrottledEffect/__docs__/example.stories.tsx b/src/useThrottledEffect/__docs__/example.stories.tsx new file mode 100644 index 00000000..de17a843 --- /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 once 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 00000000..7ae21091 --- /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 00000000..70d8402e --- /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 00000000..cab21284 --- /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 00000000..018aa698 --- /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 00000000..7c461bf6 --- /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 00000000..17433e88 --- /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 00000000..3aab231d --- /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 00000000..8648cabe --- /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 00000000..9e79e7da --- /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)]; +}