Skip to content

Commit

Permalink
feat: add useThrottledEventHandler (#30)
Browse files Browse the repository at this point in the history
* feat: add useThrottledEventHandler

* naming
  • Loading branch information
jquense authored May 13, 2020
1 parent 1098eaf commit f03d52b
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 4 deletions.
2 changes: 1 addition & 1 deletion src/useForceUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useReducer } from 'react'
* return <button type="button" onClick={updateOnClick}>Hi there</button>
* ```
*/
export default function useForceUpdate() {
export default function useForceUpdate(): () => void {
// The toggling state value is designed to defeat React optimizations for skipping
// updates when they are stricting equal to the last state value
const [, dispatch] = useReducer((state: boolean) => !state, false)
Expand Down
4 changes: 1 addition & 3 deletions src/useStateAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export type AsyncSetState<TState> = (
*
* @param initialState initialize with some state value same as `useState`
*/
function useStateAsync<TState>(
export default function useStateAsync<TState>(
initialState: TState | (() => TState),
): [TState, AsyncSetState<TState>] {
const [state, setState] = useState(initialState)
Expand Down Expand Up @@ -67,5 +67,3 @@ function useStateAsync<TState>(
)
return [state, setStateAsync]
}

export default useStateAsync
95 changes: 95 additions & 0 deletions src/useThrottledEventHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useRef, SyntheticEvent } from 'react'
import useMounted from './useMounted'
import useEventCallback from './useEventCallback'

const isSyntheticEvent = (event: any): event is SyntheticEvent =>
typeof event.persist === 'function'

export type ThrottledHandler<TEvent> = ((event: TEvent) => void) & {
clear(): void
}

/**
* Creates a event handler function throttled by `requestAnimationFrame` that
* returns the **most recent** event. Useful for noisy events that update react state.
*
* ```tsx
* function Component() {
* const [position, setPosition] = useState();
* const handleMove = useThrottledEventHandler<React.PointerEvent>(
* (event) => {
* setPosition({
* top: event.clientX,
* left: event.clientY,
* })
* }
* )
*
* return (
* <div onPointerMove={handleMove}>
* <div style={position} />
* </div>
* );
* }
* ```
*
* @param handler An event handler function
* @typeParam TEvent The event object passed to the handler function
* @returns The event handler with a `clear` method attached for clearing any in-flight handler calls
*
*/
export default function useThrottledEventHandler<TEvent = SyntheticEvent>(
handler: (event: TEvent) => void,
): ThrottledHandler<TEvent> {
const isMounted = useMounted()
const eventHandler = useEventCallback(handler)

const nextEventInfoRef = useRef<{
event: TEvent | null
handle: null | number
}>({
event: null,
handle: null,
})

const clear = () => {
cancelAnimationFrame(nextEventInfoRef.current.handle!)
nextEventInfoRef.current.handle = null
}

const handlePointerMoveAnimation = () => {
const { current: next } = nextEventInfoRef

if (next.handle && next.event) {
if (isMounted()) {
next.handle = null
eventHandler(next.event)
}
}
next.event = null
}

const throttledHandler = (event: TEvent) => {
if (!isMounted()) return

if (isSyntheticEvent(event)) {
event.persist()
}
// Special handling for a React.Konva event which reuses the
// event object as it bubbles, setting target
else if ('evt' in event) {
event = { ...event }
}

nextEventInfoRef.current.event = event
if (!nextEventInfoRef.current.handle) {
nextEventInfoRef.current.handle = requestAnimationFrame(
handlePointerMoveAnimation,
)
}
}

throttledHandler.clear = clear

return throttledHandler
}
60 changes: 60 additions & 0 deletions test/useThrottledEventHandler.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import useThrottledEventHandler from '../src/useThrottledEventHandler'
import { renderHook } from './helpers'

describe('useThrottledEventHandler', () => {
it('should throttle and use return the most recent event', done => {
const spy = jest.fn()

const [handler, wrapper] = renderHook(() =>
useThrottledEventHandler<MouseEvent>(spy),
)

const events = [
new MouseEvent('pointermove'),
new MouseEvent('pointermove'),
new MouseEvent('pointermove'),
]

events.forEach(handler)

expect(spy).not.toHaveBeenCalled()

setTimeout(() => {
expect(spy).toHaveBeenCalledTimes(1)

expect(spy).toHaveBeenCalledWith(events[events.length - 1])

wrapper.unmount()

handler(new MouseEvent('pointermove'))

setTimeout(() => {
expect(spy).toHaveBeenCalledTimes(1)

done()
}, 20)
}, 20)
})

it('should clear pending handler calls', done => {
const spy = jest.fn()

const [handler, wrapper] = renderHook(() =>
useThrottledEventHandler<MouseEvent>(spy),
)
;[
new MouseEvent('pointermove'),
new MouseEvent('pointermove'),
new MouseEvent('pointermove'),
].forEach(handler)

expect(spy).not.toHaveBeenCalled()

handler.clear()

setTimeout(() => {
expect(spy).toHaveBeenCalledTimes(0)
done()
}, 20)
})
})

0 comments on commit f03d52b

Please sign in to comment.