Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gitbook/reactjrx/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
29 changes: 29 additions & 0 deletions gitbook/reactjrx/utils/react/usedebounced.md
Original file line number Diff line number Diff line change
@@ -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 <input onChange={(event) => 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<T>(
func: ((arg: T) => void) | (() => void),
wait: number,
options?: { cancelOnUnmount?: boolean },
): (arg: T) => void
```
82 changes: 82 additions & 0 deletions src/lib/utils/react/useDebounced.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
29 changes: 18 additions & 11 deletions src/lib/utils/react/useDebounced.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(
Expand All @@ -14,21 +16,26 @@ export const useDebounced = <T>(
} = {},
) => {
const { cancelOnUnmount = false } = options
const funcRef = useLiveRef(func)
const cancelOnUnmountRef = useLiveRef(cancelOnUnmount)
const timeout = useRef<ReturnType<typeof setTimeout> | 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
}
Loading