diff --git a/gitbook/reactjrx/SUMMARY.md b/gitbook/reactjrx/SUMMARY.md index 0e42dc3..8f76371 100644 --- a/gitbook/reactjrx/SUMMARY.md +++ b/gitbook/reactjrx/SUMMARY.md @@ -45,5 +45,6 @@ * [useEffectOnce](utils/react/useeffectonce.md) * [useConstant](utils/react/useconstant.md) * [useLiveRef](utils/react/useliveref.md) + * [useDebounced](utils/react/usedebounced.md) * [RxJS](utils/rxjs/README.md) * [retryBackoff](utils/rxjs/retrybackoff.md) diff --git a/gitbook/reactjrx/utils/react/usedebounced.md b/gitbook/reactjrx/utils/react/usedebounced.md new file mode 100644 index 0000000..6685822 --- /dev/null +++ b/gitbook/reactjrx/utils/react/usedebounced.md @@ -0,0 +1,29 @@ +# useDebounced + +Returns a function that runs `func` when the calls stop for `wait` ms. Each call restarts the delay, and only the last call runs, with its argument. + +### Usage + +```tsx +import { useDebounced } from "reactjrx" + +const Search = ({ onSearch }: { onSearch: (text: string) => void }) => { + const search = useDebounced(onSearch, 300) + + return search(event.target.value)} /> +} +``` + +When the delay ends, the call runs the latest `func`. So a new `onSearch` from a later render applies to a pending call. The returned function changes only when `wait` changes. + +If the component unmounts, a pending call still runs when the delay ends, so a save is not lost. Pass `cancelOnUnmount: true` to cancel it. + +### Reference + +```typescript +function useDebounced( + func: ((arg: T) => void) | (() => void), + wait: number, + options?: { cancelOnUnmount?: boolean }, +): (arg: T) => void +``` diff --git a/src/lib/utils/react/useDebounced.test.ts b/src/lib/utils/react/useDebounced.test.ts new file mode 100644 index 0000000..1e8aa5e --- /dev/null +++ b/src/lib/utils/react/useDebounced.test.ts @@ -0,0 +1,82 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { useDebounced } from "./useDebounced" + +describe("useDebounced", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("should run func once, with the last argument of a burst of calls", () => { + const func = vi.fn() + const { result } = renderHook(() => useDebounced(func, 100)) + + result.current("first") + act(() => { + vi.advanceTimersByTime(50) + }) + result.current("second") + act(() => { + vi.advanceTimersByTime(99) + }) + + expect(func).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(1) + }) + + expect(func).toHaveBeenCalledTimes(1) + expect(func).toHaveBeenCalledWith("second") + }) + + it("should run the latest func when the delay ends", () => { + const first = vi.fn() + const latest = vi.fn() + const { result, rerender } = renderHook( + ({ func }) => useDebounced(func, 100), + { initialProps: { func: first } }, + ) + + result.current("value") + rerender({ func: latest }) + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(first).not.toHaveBeenCalled() + expect(latest).toHaveBeenCalledWith("value") + }) + + it("should run a pending call after the component unmounts", () => { + const func = vi.fn() + const { result, unmount } = renderHook(() => useDebounced(func, 100)) + + result.current("value") + unmount() + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(func).toHaveBeenCalledWith("value") + }) + + it("should cancel a pending call on unmount with cancelOnUnmount: true", () => { + const func = vi.fn() + const { result, unmount } = renderHook(() => + useDebounced(func, 100, { cancelOnUnmount: true }), + ) + + result.current("value") + unmount() + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(func).not.toHaveBeenCalled() + }) +}) diff --git a/src/lib/utils/react/useDebounced.ts b/src/lib/utils/react/useDebounced.ts index bfe18e9..44b001b 100644 --- a/src/lib/utils/react/useDebounced.ts +++ b/src/lib/utils/react/useDebounced.ts @@ -1,9 +1,11 @@ -import { useCallback, useRef } from "react" -import { useUnmount } from "./useUnmount" +import { useCallback, useEffect, useRef } from "react" +import { useLiveRef } from "./useLiveRef" /** - * @param func Function to be debounced - * @param wait time in ms to debounce function, default to 300 + * @param func Function to be debounced. The latest `func` runs when the delay ends. + * @param wait Delay in ms, restarted by each call. + * @param options.cancelOnUnmount Cancels a pending call when the component + * unmounts. Defaults to `false`, so a pending call, such as a save, still runs. * @returns the debounced callback function */ export const useDebounced = ( @@ -14,21 +16,26 @@ export const useDebounced = ( } = {}, ) => { const { cancelOnUnmount = false } = options + const funcRef = useLiveRef(func) + const cancelOnUnmountRef = useLiveRef(cancelOnUnmount) const timeout = useRef | null>(null) const callback = useCallback( (arg: T) => { timeout.current !== null && clearTimeout(timeout.current) - timeout.current = setTimeout(() => func(arg), wait) + timeout.current = setTimeout(() => funcRef.current(arg), wait) }, - [func, wait], + [funcRef, wait], ) - useUnmount(() => { - if (cancelOnUnmount) { - timeout.current && clearTimeout(timeout.current) - } - }) + useEffect( + () => () => { + if (cancelOnUnmountRef.current && timeout.current !== null) { + clearTimeout(timeout.current) + } + }, + [cancelOnUnmountRef], + ) return callback }