diff --git a/README.md b/README.md index 40b9fdaff..132a5bf6f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ import { useMountEffect } from "@react-hookz/web/esnext"; — Makes passed function debounced, otherwise acts like `useCallback`. - [**`useRafCallback`**](https://react-hookz.github.io/web/?path=/docs/callback-userafcallback) — Makes passed function to be called within next animation frame. + - [**`useThrottleCallback`**](https://react-hookz.github.io/web/?path=/docs/callback-usethrottlecallback) + — Makes passed function throttled, otherwise acts like `useCallback`. - #### Lifecycle diff --git a/src/index.ts b/src/index.ts index 6c7df2d70..fa6b22bf0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ // Callback export { useDebounceCallback } from './useDebounceCallback/useDebounceCallback'; export { useRafCallback } from './useRafCallback/useRafCallback'; +export { useThrottleCallback } from './useThrottleCallback/useThrottleCallback'; // Livecycle export { diff --git a/src/useCookieValue/useCookieValue.ts b/src/useCookieValue/useCookieValue.ts index d0658f374..a53a52b74 100644 --- a/src/useCookieValue/useCookieValue.ts +++ b/src/useCookieValue/useCookieValue.ts @@ -2,7 +2,6 @@ import { Dispatch, useCallback, useEffect } from 'react'; import * as Cookies from 'js-cookie'; import { useSafeState, useSyncedRef, useFirstMountState, useMountEffect } from '..'; - import { isBrowser } from '../util/const'; const cookiesSetters = new Map<string, Set<Dispatch<string | null>>>(); diff --git a/src/useStorageValue/useStorageValue.ts b/src/useStorageValue/useStorageValue.ts index 9483f3a3b..7eefeaf8c 100644 --- a/src/useStorageValue/useStorageValue.ts +++ b/src/useStorageValue/useStorageValue.ts @@ -7,14 +7,10 @@ import { useSyncedRef, useFirstMountState, usePrevious, + useMountEffect, } from '..'; - import { INextState, resolveHookState } from '../util/resolveHookState'; - -import { useMountEffect } from '../useMountEffect/useMountEffect'; - import { isBrowser } from '../util/const'; - import { off, on } from '../util/misc'; export type IUseStorageValueOptions< diff --git a/src/useThrottleCallback/__docs__/example.stories.tsx b/src/useThrottleCallback/__docs__/example.stories.tsx new file mode 100644 index 000000000..346ad67e9 --- /dev/null +++ b/src/useThrottleCallback/__docs__/example.stories.tsx @@ -0,0 +1,23 @@ +import React, { useState } from 'react'; +import { useThrottleCallback } from '../..'; + +export const Example: React.FC = () => { + const [state, setState] = useState(''); + + const handleChange: React.ChangeEventHandler<HTMLInputElement> = useThrottleCallback( + (ev) => { + setState(ev.target.value); + }, + 500, + [] + ); + + return ( + <div> + <div>Below state will update no more than once every 500ms</div> + <br /> + <div>The input`s value is: {state}</div> + <input type="text" onChange={handleChange} /> + </div> + ); +}; diff --git a/src/useThrottleCallback/__docs__/story.mdx b/src/useThrottleCallback/__docs__/story.mdx new file mode 100644 index 000000000..eac4b8740 --- /dev/null +++ b/src/useThrottleCallback/__docs__/story.mdx @@ -0,0 +1,37 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './example.stories'; + +<Meta title="Callback/useThrottleCallback" component={Example} /> + +# useThrottleCallback + +Makes passed function throttled, otherwise acts like `useCallback`. +[\[What is throttling?\]](https://css-tricks.com/debouncing-throttling-explained-examples/#throttle) + +> The third argument is dependencies list in `useEffect` manner, passed function will be re-wrapped +> when delay or dependencies has changed. Changed throttle callbacks still have same timeout, meaning +> that calling new throttled function will abort previously scheduled invocation. + +> Throttled function is always a void function since original callback invoked later. + +#### Example + +<Canvas> + <Story story={Example} inline /> +</Canvas> + +## Reference + +```ts +function useThrottleCallback<T extends unknown[]>( + cb: (...args: T) => unknown, + delay: number, + deps: React.DependencyList +): (...args: T) => void; +``` + +- **cb** _`(...args: T) => unknown`_ - function that will be throttled. +- **delay** _`number`_ - throttle delay. +- **deps** _`React.DependencyList`_ - dependencies list when to update callback. +- **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/useThrottleCallback/__tests__/dom.ts b/src/useThrottleCallback/__tests__/dom.ts new file mode 100644 index 000000000..78f059c29 --- /dev/null +++ b/src/useThrottleCallback/__tests__/dom.ts @@ -0,0 +1,112 @@ +import { renderHook } from '@testing-library/react-hooks/dom'; +import { useThrottleCallback } from '../..'; + +describe('useThrottleCallback', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useThrottleCallback).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => { + useThrottleCallback(() => {}, 200, []); + }); + expect(result.error).toBeUndefined(); + }); + + it('should return function same length and wrapped name', () => { + let { result } = renderHook(() => + useThrottleCallback((_a: any, _b: any, _c: any) => {}, 200, []) + ); + + expect(result.current.length).toBe(3); + expect(result.current.name).toBe(`anonymous__throttled__200`); + + function testFn(_a: any, _b: any, _c: any) {} + + result = renderHook(() => useThrottleCallback(testFn, 100, [])).result; + + expect(result.current.length).toBe(3); + expect(result.current.name).toBe(`testFn__throttled__100`); + }); + + it('should return new callback if delay is changed', () => { + const { result, rerender } = renderHook( + ({ delay }) => useThrottleCallback(() => {}, delay, []), + { + initialProps: { delay: 200 }, + } + ); + + const cb1 = result.current; + rerender({ delay: 123 }); + + expect(cb1).not.toBe(result.current); + }); + + it('should invoke given callback immediately', () => { + const cb = jest.fn(); + const { result } = renderHook(() => useThrottleCallback(cb, 200, [])); + + result.current(); + 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(() => useThrottleCallback(cb, 200, [])); + + result.current(1, 'abc'); + expect(cb).toHaveBeenCalledWith(1, 'abc'); + }); + + it('should ignore consequential calls occurred within delay, but execute last call after delay is passed', () => { + const cb = jest.fn(); + const { result } = renderHook(() => useThrottleCallback(cb, 200, [])); + + result.current(); + result.current(); + result.current(); + result.current(); + expect(cb).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(199); + result.current(); + expect(cb).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(1); + expect(cb).toHaveBeenCalledTimes(2); + result.current(); + expect(cb).toHaveBeenCalledTimes(2); + jest.advanceTimersByTime(200); + expect(cb).toHaveBeenCalledTimes(3); + }); + + it('should drop trailing execution if `noTrailing is set to true`', () => { + const cb = jest.fn(); + const { result } = renderHook(() => useThrottleCallback(cb, 200, [], true)); + + result.current(); + result.current(); + result.current(); + result.current(); + expect(cb).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(199); + result.current(); + expect(cb).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(1); + expect(cb).toHaveBeenCalledTimes(1); + result.current(); + result.current(); + result.current(); + expect(cb).toHaveBeenCalledTimes(2); + jest.advanceTimersByTime(200); + expect(cb).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/useThrottleCallback/__tests__/ssr.ts b/src/useThrottleCallback/__tests__/ssr.ts new file mode 100644 index 000000000..1c489edd8 --- /dev/null +++ b/src/useThrottleCallback/__tests__/ssr.ts @@ -0,0 +1,41 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useThrottleCallback } from '../..'; + +describe('useThrottleCallback', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useThrottleCallback).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => { + useThrottleCallback(() => {}, 200, []); + }); + expect(result.error).toBeUndefined(); + }); + + it('should invoke given callback immediately', () => { + const cb = jest.fn(); + const { result } = renderHook(() => useThrottleCallback(cb, 200, [])); + + result.current(); + 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(() => useThrottleCallback(cb, 200, [])); + + result.current(1, 'abc'); + jest.advanceTimersByTime(200); + expect(cb).toHaveBeenCalledWith(1, 'abc'); + }); +}); diff --git a/src/useThrottleCallback/useThrottleCallback.ts b/src/useThrottleCallback/useThrottleCallback.ts new file mode 100644 index 000000000..dc5672d88 --- /dev/null +++ b/src/useThrottleCallback/useThrottleCallback.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { DependencyList, useMemo, useRef } from 'react'; +import { useUnmountEffect } from '..'; + +export interface IThrottledFunction<Args extends any[], This> { + (this: This, ...args: Args): void; +} + +/** + * Makes passed function throttled, otherwise acts like `useCallback`. + * + * @param callback Function that will be throttled. + * @param delay Throttle delay. + * @param deps Dependencies list when to update callback. + * @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 useThrottleCallback<Args extends any[], This>( + callback: (this: This, ...args: Args) => any, + delay: number, + deps: DependencyList, + noTrailing = false +): IThrottledFunction<Args, This> { + const timeout = useRef<ReturnType<typeof setTimeout>>(); + const lastCall = useRef<{ args: Args; this: This }>(); + + useUnmountEffect(() => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }); + + return useMemo(() => { + const execute = (context: This, args: Args) => { + lastCall.current = undefined; + callback.apply(context, args); + + timeout.current = setTimeout(() => { + timeout.current = undefined; + + // if trailing execution is not disabled - call callback with last + // received arguments and context + if (!noTrailing && lastCall.current) { + execute(lastCall.current.this, lastCall.current.args); + + lastCall.current = undefined; + } + }, delay); + }; + + // eslint-disable-next-line func-names + const wrapped = function (this, ...args) { + if (timeout.current) { + // if we cant execute callback immediately - save its arguments and + // context to execute it when delay is passed + lastCall.current = { args, this: this }; + + return; + } + + execute(this, args); + } as IThrottledFunction<Args, This>; + + Object.defineProperties(wrapped, { + length: { value: callback.length }, + name: { value: `${callback.name || 'anonymous'}__throttled__${delay}` }, + }); + + return wrapped; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [delay, noTrailing, ...deps]); +}