From 38691646549d8bce4b5a0853c5ecbd296b8048dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:41:15 +0000 Subject: [PATCH 1/5] fix(utils): cancel useDebounced on unmount by default, and run the latest func A call that was pending when the component unmounted still ran, unless the caller passed `cancelOnUnmount: true`. A call also ran the `func` of the render that made it, even if a later render passed a new one. `cancelOnUnmount` now defaults to `true`, and a call runs the latest `func` when the delay ends. The returned function changes only when `wait` changes. Closes #225. Claude-Session: https://claude.ai/code/session_01BvSDyJABLGit75HAjpnsqm BREAKING CHANGE: `useDebounced` cancels a pending call when the component unmounts. To run it after the unmount, as before, pass `{ cancelOnUnmount: false }`. --- src/lib/utils/react/useDebounced.test.ts | 82 ++++++++++++++++++++++++ src/lib/utils/react/useDebounced.ts | 30 +++++---- 2 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 src/lib/utils/react/useDebounced.test.ts diff --git a/src/lib/utils/react/useDebounced.test.ts b/src/lib/utils/react/useDebounced.test.ts new file mode 100644 index 00000000..d39e44d1 --- /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 cancel a pending call when the component unmounts", () => { + const func = vi.fn() + const { result, unmount } = renderHook(() => useDebounced(func, 100)) + + result.current("value") + unmount() + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(func).not.toHaveBeenCalled() + }) + + it("should run a pending call after unmount with cancelOnUnmount: false", () => { + const func = vi.fn() + const { result, unmount } = renderHook(() => + useDebounced(func, 100, { cancelOnUnmount: false }), + ) + + result.current("value") + unmount() + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(func).toHaveBeenCalledWith("value") + }) +}) diff --git a/src/lib/utils/react/useDebounced.ts b/src/lib/utils/react/useDebounced.ts index bfe18e93..de1e0367 100644 --- a/src/lib/utils/react/useDebounced.ts +++ b/src/lib/utils/react/useDebounced.ts @@ -1,9 +1,10 @@ -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 `true`. * @returns the debounced callback function */ export const useDebounced = ( @@ -13,22 +14,27 @@ export const useDebounced = ( cancelOnUnmount?: boolean } = {}, ) => { - const { cancelOnUnmount = false } = options + const { cancelOnUnmount = true } = 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 } From 582063dc4c1c75fef6f817453e647a93c3e624a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:41:15 +0000 Subject: [PATCH 2/5] docs(utils): add a useDebounced page Claude-Session: https://claude.ai/code/session_01BvSDyJABLGit75HAjpnsqm --- gitbook/reactjrx/SUMMARY.md | 1 + gitbook/reactjrx/utils/react/usedebounced.md | 29 ++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 gitbook/reactjrx/utils/react/usedebounced.md diff --git a/gitbook/reactjrx/SUMMARY.md b/gitbook/reactjrx/SUMMARY.md index 0e42dc37..8f763715 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 00000000..46f02be7 --- /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 does not run. Pass `cancelOnUnmount: false` to run it after the unmount. + +### Reference + +```typescript +function useDebounced( + func: ((arg: T) => void) | (() => void), + wait: number, + options?: { cancelOnUnmount?: boolean }, +): (arg: T) => void +``` From 04e0c02fac445b083856bcdff712100c83253e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:54:40 +0000 Subject: [PATCH 3/5] Revert "fix(utils): cancel useDebounced on unmount by default, and run the latest func" This reverts commit 38691646549d8bce4b5a0853c5ecbd296b8048dc. Cancelling a pending call on unmount loses its work, such as the last save of a form, without an error. Running it late is mostly harmless. So `cancelOnUnmount` stays `false` by default. The next commit adds the latest-func fix again, without the breaking change. Claude-Session: https://claude.ai/code/session_01BvSDyJABLGit75HAjpnsqm --- src/lib/utils/react/useDebounced.test.ts | 82 ------------------------ src/lib/utils/react/useDebounced.ts | 30 ++++----- 2 files changed, 12 insertions(+), 100 deletions(-) delete mode 100644 src/lib/utils/react/useDebounced.test.ts diff --git a/src/lib/utils/react/useDebounced.test.ts b/src/lib/utils/react/useDebounced.test.ts deleted file mode 100644 index d39e44d1..00000000 --- a/src/lib/utils/react/useDebounced.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 cancel a pending call when the component unmounts", () => { - const func = vi.fn() - const { result, unmount } = renderHook(() => useDebounced(func, 100)) - - result.current("value") - unmount() - act(() => { - vi.advanceTimersByTime(100) - }) - - expect(func).not.toHaveBeenCalled() - }) - - it("should run a pending call after unmount with cancelOnUnmount: false", () => { - const func = vi.fn() - const { result, unmount } = renderHook(() => - useDebounced(func, 100, { cancelOnUnmount: false }), - ) - - result.current("value") - unmount() - act(() => { - vi.advanceTimersByTime(100) - }) - - expect(func).toHaveBeenCalledWith("value") - }) -}) diff --git a/src/lib/utils/react/useDebounced.ts b/src/lib/utils/react/useDebounced.ts index de1e0367..bfe18e93 100644 --- a/src/lib/utils/react/useDebounced.ts +++ b/src/lib/utils/react/useDebounced.ts @@ -1,10 +1,9 @@ -import { useCallback, useEffect, useRef } from "react" -import { useLiveRef } from "./useLiveRef" +import { useCallback, useRef } from "react" +import { useUnmount } from "./useUnmount" /** - * @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 `true`. + * @param func Function to be debounced + * @param wait time in ms to debounce function, default to 300 * @returns the debounced callback function */ export const useDebounced = ( @@ -14,27 +13,22 @@ export const useDebounced = ( cancelOnUnmount?: boolean } = {}, ) => { - const { cancelOnUnmount = true } = options - const funcRef = useLiveRef(func) - const cancelOnUnmountRef = useLiveRef(cancelOnUnmount) + const { cancelOnUnmount = false } = options const timeout = useRef | null>(null) const callback = useCallback( (arg: T) => { timeout.current !== null && clearTimeout(timeout.current) - timeout.current = setTimeout(() => funcRef.current(arg), wait) + timeout.current = setTimeout(() => func(arg), wait) }, - [funcRef, wait], + [func, wait], ) - useEffect( - () => () => { - if (cancelOnUnmountRef.current && timeout.current !== null) { - clearTimeout(timeout.current) - } - }, - [cancelOnUnmountRef], - ) + useUnmount(() => { + if (cancelOnUnmount) { + timeout.current && clearTimeout(timeout.current) + } + }) return callback } From dfdba171bfe70dce875509dc0afc7f0d8afa2da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:55:38 +0000 Subject: [PATCH 4/5] fix(utils): run the latest func when a useDebounced call fires A call ran the `func` of the render that made it, even if a later render passed a new one. It now runs the latest `func`, and the returned function changes only when `wait` changes. `cancelOnUnmount` keeps its `false` default, so a pending call, such as a save, still runs after the component unmounts. Closes #225. Claude-Session: https://claude.ai/code/session_01BvSDyJABLGit75HAjpnsqm --- src/lib/utils/react/useDebounced.test.ts | 82 ++++++++++++++++++++++++ src/lib/utils/react/useDebounced.ts | 29 +++++---- 2 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 src/lib/utils/react/useDebounced.test.ts diff --git a/src/lib/utils/react/useDebounced.test.ts b/src/lib/utils/react/useDebounced.test.ts new file mode 100644 index 00000000..1e8aa5e6 --- /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 bfe18e93..44b001b0 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 } From 2fe402b997dd28687bfd064e941aa82e9cacdf75 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 21:55:39 +0000 Subject: [PATCH 5/5] docs(utils): say that a pending useDebounced call runs after unmount Claude-Session: https://claude.ai/code/session_01BvSDyJABLGit75HAjpnsqm --- gitbook/reactjrx/utils/react/usedebounced.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitbook/reactjrx/utils/react/usedebounced.md b/gitbook/reactjrx/utils/react/usedebounced.md index 46f02be7..6685822e 100644 --- a/gitbook/reactjrx/utils/react/usedebounced.md +++ b/gitbook/reactjrx/utils/react/usedebounced.md @@ -16,7 +16,7 @@ const Search = ({ onSearch }: { onSearch: (text: string) => void }) => { 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 does not run. Pass `cancelOnUnmount: false` to run it after the unmount. +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