Skip to content

Commit f03d52b

Browse files
authored
feat: add useThrottledEventHandler (#30)
* feat: add useThrottledEventHandler * naming
1 parent 1098eaf commit f03d52b

File tree

4 files changed

+157
-4
lines changed

4 files changed

+157
-4
lines changed

src/useForceUpdate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { useReducer } from 'react'
1616
* return <button type="button" onClick={updateOnClick}>Hi there</button>
1717
* ```
1818
*/
19-
export default function useForceUpdate() {
19+
export default function useForceUpdate(): () => void {
2020
// The toggling state value is designed to defeat React optimizations for skipping
2121
// updates when they are stricting equal to the last state value
2222
const [, dispatch] = useReducer((state: boolean) => !state, false)

src/useStateAsync.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export type AsyncSetState<TState> = (
2323
*
2424
* @param initialState initialize with some state value same as `useState`
2525
*/
26-
function useStateAsync<TState>(
26+
export default function useStateAsync<TState>(
2727
initialState: TState | (() => TState),
2828
): [TState, AsyncSetState<TState>] {
2929
const [state, setState] = useState(initialState)
@@ -67,5 +67,3 @@ function useStateAsync<TState>(
6767
)
6868
return [state, setStateAsync]
6969
}
70-
71-
export default useStateAsync

src/useThrottledEventHandler.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useRef, SyntheticEvent } from 'react'
2+
import useMounted from './useMounted'
3+
import useEventCallback from './useEventCallback'
4+
5+
const isSyntheticEvent = (event: any): event is SyntheticEvent =>
6+
typeof event.persist === 'function'
7+
8+
export type ThrottledHandler<TEvent> = ((event: TEvent) => void) & {
9+
clear(): void
10+
}
11+
12+
/**
13+
* Creates a event handler function throttled by `requestAnimationFrame` that
14+
* returns the **most recent** event. Useful for noisy events that update react state.
15+
*
16+
* ```tsx
17+
* function Component() {
18+
* const [position, setPosition] = useState();
19+
* const handleMove = useThrottledEventHandler<React.PointerEvent>(
20+
* (event) => {
21+
* setPosition({
22+
* top: event.clientX,
23+
* left: event.clientY,
24+
* })
25+
* }
26+
* )
27+
*
28+
* return (
29+
* <div onPointerMove={handleMove}>
30+
* <div style={position} />
31+
* </div>
32+
* );
33+
* }
34+
* ```
35+
*
36+
* @param handler An event handler function
37+
* @typeParam TEvent The event object passed to the handler function
38+
* @returns The event handler with a `clear` method attached for clearing any in-flight handler calls
39+
*
40+
*/
41+
export default function useThrottledEventHandler<TEvent = SyntheticEvent>(
42+
handler: (event: TEvent) => void,
43+
): ThrottledHandler<TEvent> {
44+
const isMounted = useMounted()
45+
const eventHandler = useEventCallback(handler)
46+
47+
const nextEventInfoRef = useRef<{
48+
event: TEvent | null
49+
handle: null | number
50+
}>({
51+
event: null,
52+
handle: null,
53+
})
54+
55+
const clear = () => {
56+
cancelAnimationFrame(nextEventInfoRef.current.handle!)
57+
nextEventInfoRef.current.handle = null
58+
}
59+
60+
const handlePointerMoveAnimation = () => {
61+
const { current: next } = nextEventInfoRef
62+
63+
if (next.handle && next.event) {
64+
if (isMounted()) {
65+
next.handle = null
66+
eventHandler(next.event)
67+
}
68+
}
69+
next.event = null
70+
}
71+
72+
const throttledHandler = (event: TEvent) => {
73+
if (!isMounted()) return
74+
75+
if (isSyntheticEvent(event)) {
76+
event.persist()
77+
}
78+
// Special handling for a React.Konva event which reuses the
79+
// event object as it bubbles, setting target
80+
else if ('evt' in event) {
81+
event = { ...event }
82+
}
83+
84+
nextEventInfoRef.current.event = event
85+
if (!nextEventInfoRef.current.handle) {
86+
nextEventInfoRef.current.handle = requestAnimationFrame(
87+
handlePointerMoveAnimation,
88+
)
89+
}
90+
}
91+
92+
throttledHandler.clear = clear
93+
94+
return throttledHandler
95+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import useThrottledEventHandler from '../src/useThrottledEventHandler'
2+
import { renderHook } from './helpers'
3+
4+
describe('useThrottledEventHandler', () => {
5+
it('should throttle and use return the most recent event', done => {
6+
const spy = jest.fn()
7+
8+
const [handler, wrapper] = renderHook(() =>
9+
useThrottledEventHandler<MouseEvent>(spy),
10+
)
11+
12+
const events = [
13+
new MouseEvent('pointermove'),
14+
new MouseEvent('pointermove'),
15+
new MouseEvent('pointermove'),
16+
]
17+
18+
events.forEach(handler)
19+
20+
expect(spy).not.toHaveBeenCalled()
21+
22+
setTimeout(() => {
23+
expect(spy).toHaveBeenCalledTimes(1)
24+
25+
expect(spy).toHaveBeenCalledWith(events[events.length - 1])
26+
27+
wrapper.unmount()
28+
29+
handler(new MouseEvent('pointermove'))
30+
31+
setTimeout(() => {
32+
expect(spy).toHaveBeenCalledTimes(1)
33+
34+
done()
35+
}, 20)
36+
}, 20)
37+
})
38+
39+
it('should clear pending handler calls', done => {
40+
const spy = jest.fn()
41+
42+
const [handler, wrapper] = renderHook(() =>
43+
useThrottledEventHandler<MouseEvent>(spy),
44+
)
45+
;[
46+
new MouseEvent('pointermove'),
47+
new MouseEvent('pointermove'),
48+
new MouseEvent('pointermove'),
49+
].forEach(handler)
50+
51+
expect(spy).not.toHaveBeenCalled()
52+
53+
handler.clear()
54+
55+
setTimeout(() => {
56+
expect(spy).toHaveBeenCalledTimes(0)
57+
done()
58+
}, 20)
59+
})
60+
})

0 commit comments

Comments
 (0)