Skip to content

Commit 9fce076

Browse files
committed
Add useRafDebounce hook
1 parent 0874c8b commit 9fce076

File tree

6 files changed

+127
-21
lines changed

6 files changed

+127
-21
lines changed

.mise.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
PRETTIER_EXPERIMENTAL_CLI = "1"
33

44
[tools]
5-
node = "22.21.0"
6-
pnpm = "10.19.0"
5+
node = "24.11.0"
6+
pnpm = "10.20.0"

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
22
"name": "@trashpanda001/helpers",
33
"description": "Helpers for my projects.",
4-
"version": "0.22.0",
4+
"version": "0.23.0",
55
"type": "module",
66
"engines": {
7-
"node": "^22.0.0"
7+
"node": ">=22.0.0"
88
},
9-
"packageManager": "pnpm@10.19.0",
9+
"packageManager": "pnpm@10.20.0",
1010
"license": "MIT",
1111
"repository": {
1212
"type": "git",
@@ -61,7 +61,7 @@
6161
"@testing-library/dom": "^10.4.1",
6262
"@testing-library/react": "^16.3.0",
6363
"@types/luxon": "^3.7.1",
64-
"@types/node": "^22.18.12",
64+
"@types/node": "^24.9.1",
6565
"@types/react": "^19.2.2",
6666
"@types/react-dom": "^19.2.2",
6767
"@types/string-natural-compare": "^3.0.4",

pnpm-lock.yaml

Lines changed: 20 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export { useIsMounted } from "./use-is-mounted.js"
1515
export { useIsPageVisible } from "./use-is-page-visible.js"
1616
export { useIsPortrait } from "./use-is-portrait.js"
1717
export { useMediaQuery } from "./use-media-query.js"
18+
export { useRafDebounce } from "./use-raf-debounce.js"
1819
export { useResizeObserver } from "./use-resize-observer.js"
1920
export { useToggle } from "./use-toggle.js"
2021
export { useViewport, type ViewportInfo } from "./use-viewport.js"

src/react/use-raf-debounce.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { act, renderHook } from "@testing-library/react"
2+
import { useRafDebounce } from "@trashpanda001/helpers/react"
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
4+
5+
describe("useRafDebounce", () => {
6+
let rafCallbacks: Map<number, FrameRequestCallback>
7+
let cancelledHandles: number[]
8+
let nextHandle: number
9+
10+
beforeEach(() => {
11+
nextHandle = 1
12+
rafCallbacks = new Map()
13+
cancelledHandles = []
14+
15+
const stubbedRequestAnimationFrame: typeof requestAnimationFrame = (callback) => {
16+
const handle = nextHandle++
17+
rafCallbacks.set(handle, callback)
18+
return handle
19+
}
20+
21+
const stubbedCancelAnimationFrame: typeof cancelAnimationFrame = (handle) => {
22+
cancelledHandles.push(handle)
23+
rafCallbacks.delete(handle)
24+
}
25+
26+
vi.stubGlobal("requestAnimationFrame", stubbedRequestAnimationFrame)
27+
vi.stubGlobal("cancelAnimationFrame", stubbedCancelAnimationFrame)
28+
})
29+
30+
afterEach(() => {
31+
vi.unstubAllGlobals()
32+
})
33+
34+
it("only executes the latest invocation on the next animation frame", () => {
35+
const callback = vi.fn<(value: string) => void>()
36+
const { result } = renderHook(() => useRafDebounce(callback))
37+
38+
act(() => {
39+
result.current("first")
40+
})
41+
42+
const handlesAfterFirst = [...rafCallbacks.keys()]
43+
expect(handlesAfterFirst).toHaveLength(1)
44+
const firstHandle = handlesAfterFirst[0]!
45+
46+
act(() => {
47+
result.current("second")
48+
})
49+
50+
const handles = [...rafCallbacks.keys()]
51+
expect(handles).toHaveLength(1)
52+
const latestHandle = handles[0]!
53+
54+
expect(cancelledHandles).toContain(firstHandle)
55+
expect(callback).not.toHaveBeenCalled()
56+
57+
act(() => {
58+
const frame = rafCallbacks.get(latestHandle)
59+
expect(frame).toBeDefined()
60+
frame?.(16 as DOMHighResTimeStamp)
61+
})
62+
63+
expect(callback).toHaveBeenCalledTimes(1)
64+
expect(callback).toHaveBeenCalledWith("second")
65+
})
66+
})

src/react/use-raf-debounce.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useCallback, useRef } from "react"
2+
3+
/**
4+
* Debounces a callback using requestAnimationFrame to sync with display refresh rate.
5+
*
6+
* This is ideal for high-frequency events like scroll, resize, or mousemove that need
7+
* to update the UI. Instead of running on every event (which can be 1000+ times/sec),
8+
* the callback runs at the display refresh rate (60fps/120fps).
9+
*
10+
* @param callback - Function to debounce
11+
* @returns Debounced function that runs at display refresh rate
12+
*
13+
* @example
14+
* ```tsx
15+
* const debouncedScroll = useRafDebounce(() => {
16+
* updateScrollPosition()
17+
* })
18+
*
19+
* window.addEventListener('scroll', debouncedScroll, { passive: true })
20+
* window.removeEventListener('scroll', debouncedScroll)
21+
* ```
22+
*/
23+
export function useRafDebounce<T extends unknown[]>(callback: (...args: T) => void): (...args: T) => void {
24+
const handleRef = useRef(0)
25+
return useCallback(
26+
(...args: T) => {
27+
cancelAnimationFrame(handleRef.current)
28+
handleRef.current = requestAnimationFrame((_time: DOMHighResTimeStamp) => {
29+
callback(...args)
30+
})
31+
},
32+
[callback],
33+
)
34+
}

0 commit comments

Comments
 (0)