diff --git a/docs/index.md b/docs/index.md index bf1c41ac..9c166b6b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -179,6 +179,9 @@ features: - title: useWindowSize details: Hook that manages a window size link: /functions/hooks/useWindowSize +- title: useLockFn + details: A hook that creates a function which ensures that only one instance of the given asynchronous function can run at a time. + link: /functions/hooks/useLockFn --- diff --git a/src/hooks/useLockFn/useLockFn.demo.tsx b/src/hooks/useLockFn/useLockFn.demo.tsx new file mode 100644 index 00000000..4b5c44d7 --- /dev/null +++ b/src/hooks/useLockFn/useLockFn.demo.tsx @@ -0,0 +1,32 @@ +import { useState } from 'react'; + +import { useLockFn } from '@/hooks/useLockFn/useLockFn'; + +const mockApiRequest = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 2000); + }); +}; + +const Demo = () => { + const [count, setCount] = useState(0); + const [message, setMessage] = useState(''); + const submit = useLockFn(async () => { + setMessage('Start to submit'); + await mockApiRequest(); + setCount((val) => val + 1); + setMessage('Submit finished'); + }); + + return ( + <> +

Submit count: {count}

+ {message &&

Message: {message}

} + + + ); +}; + +export default Demo; diff --git a/src/hooks/useLockFn/useLockFn.test.ts b/src/hooks/useLockFn/useLockFn.test.ts new file mode 100644 index 00000000..7f7fd2fa --- /dev/null +++ b/src/hooks/useLockFn/useLockFn.test.ts @@ -0,0 +1,75 @@ +import type { MutableRefObject } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import type { RenderHookResult } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; + +import { useLockFn } from '@/hooks/useLockFn/useLockFn'; + +type HookReturnType = { + locked: (step: number) => Promise; + countRef: MutableRefObject; + updateTag: () => void; +}; + +const sleep = (time: number) => + new Promise((resolve) => { + setTimeout(resolve, time); + }); + +describe('useLockFn', () => { + const setUp = (): RenderHookResult => + renderHook(() => { + const [tag, updateTag] = useState(false); + const countRef = useRef(0); + const persistFn = useCallback( + async (step: number) => { + countRef.current += step; + await sleep(50); + }, + [tag] + ); + const locked = useLockFn(persistFn); + + return { + locked, + countRef, + updateTag: () => updateTag(true) + }; + }); + + it('should work', async () => { + const hook = setUp(); + const { locked, countRef } = hook.result.current; + act(() => { + locked(1); + }); + expect(countRef.current).toBe(1); + act(() => { + locked(2); + }); + expect(countRef.current).toBe(1); + await sleep(30); + act(() => { + locked(3); + }); + expect(countRef.current).toBe(1); + await sleep(30); + act(() => { + locked(4); + }); + expect(countRef.current).toBe(5); + act(() => { + locked(5); + }); + expect(countRef.current).toBe(5); + }); + + it('should same', () => { + const hook = setUp(); + const preLocked = hook.result.current.locked; + hook.rerender(); + expect(hook.result.current.locked).toEqual(preLocked); + act(hook.result.current.updateTag); + expect(hook.result.current.locked).not.toEqual(preLocked); + }); +}); diff --git a/src/hooks/useLockFn/useLockFn.ts b/src/hooks/useLockFn/useLockFn.ts new file mode 100644 index 00000000..5b0bb373 --- /dev/null +++ b/src/hooks/useLockFn/useLockFn.ts @@ -0,0 +1,36 @@ +import { useCallback, useRef } from 'react'; + +/** + * @name useLockFn + * @description A hook that creates a function which ensures that only one instance of the given asynchronous function can run at a time. + * + * @template P - The parameters type of the function. + * @template V - The return type of the function. + * @param {(...args: P) => Promise} fn - The asynchronous function to be locked. + * @returns {(...args: P) => Promise} A function that ensures only one instance of the given function can run at a time. + * + * @example + * const lockedFunction = useLockFn(asyncFunction); + * lockedFunction(arg1, arg2).then(result => { + * console.log(result); + * }).catch(error => { + * console.error(error); + * }); + */ + +export const useLockFn =

(fn: (...args: P) => Promise) => { + const lockRef = useRef(false); + + return useCallback( + async (...args: P): Promise => { + if (lockRef.current) return; + lockRef.current = true; + try { + return await fn(...args); + } finally { + lockRef.current = false; + } + }, + [fn] + ); +};