Skip to content

Instantly share code, notes, and snippets.

@astoilkov
Last active March 16, 2020 08:22
Show Gist options
  • Save astoilkov/a5a6e6c6d11a48fe049ee4d25230653f to your computer and use it in GitHub Desktop.
Save astoilkov/a5a6e6c6d11a48fe049ee4d25230653f to your computer and use it in GitHub Desktop.
React hook for debouncing callbacks
import { useCallback, useLayoutEffect, DependencyList } from 'react'
/**
* This version bounces each version of the callback. This ensures that the callback
* will be called with each state of the application. That's why `deps` is a required argument.
*
* Previous version used `useRef` for `timeoutId` and didn't have `deps` argument.
* This resulted in missing calling the callback for the previous state and necessarily
* calling it for the new state.
*
* Note: In theory bounced callbacks shouldn't access `Ref` instances inside of the
* callback. The reason is that `Ref` instance values can change between the time
* the callback is executed and the time the bouncing decides to execute it.
* That doesn't apply for state and props because they are bound to the current
* function context.
*/
export default function useDebounceCallback<T extends (...args: any[]) => void>(
callback: T,
delay: number,
deps: DependencyList,
): T {
let disposed = false
let timeoutId: number | undefined
let callbackWrapper: Function | undefined
/**
* Ensures the callback is called immediately after `deps` change
* instead of waiting for the `setTimeout` to fire.
*
* Using `useLayoutEffect` instead of `useEffect` because otherwise the
* callback will be called after all `useLayoutEffect` are executed.
* `useLayoutEffect` hooks can change `Ref` values and thus change the
* `Ref` values accessed in the bounced callback.
*/
useLayoutEffect(() => {
return () => {
/**
* Disabling `react-hooks/exhaustive-deps` because we intentionally want to not use refs for
* the `disposed` property:
* Assignments to the 'disposed' variable from inside React Hook useLayoutEffect will be lost
* after each render. To preserve the value over time, store it in a useRef Hook and keep the
* mutable value in the '.current' property. Otherwise, you can move this variable directly
* inside useLayoutEffect.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
disposed = true
if (callbackWrapper === undefined || timeoutId === undefined) {
return
}
callbackWrapper()
clearTimeout(timeoutId)
}
}, deps)
return useCallback<T>(
function useDebounceCallback(...args) {
if (disposed) {
throw new Error(
[
'Trying to call an already disposed callback.',
'In theory you should never call a disposed callback.',
'This is probably a bug.',
].join(' '),
)
}
clearTimeout(timeoutId)
callbackWrapper = () => {
timeoutId = undefined
return callback(...args)
}
timeoutId = window.setTimeout(callbackWrapper, delay)
} as T,
deps,
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment