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
}