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
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)
})
})