Skip to content

Commit

Permalink
feat: add leading, and maxWait options to debounce hooks (#97)
Browse files Browse the repository at this point in the history
  • Loading branch information
jquense authored Dec 6, 2023
1 parent 2464d25 commit c8d69b2
Show file tree
Hide file tree
Showing 6 changed files with 417 additions and 31 deletions.
144 changes: 131 additions & 13 deletions src/useDebouncedCallback.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,141 @@
import { useCallback } from 'react'
import { useCallback, useMemo, useRef } from 'react'
import useTimeout from './useTimeout'
import useMounted from './useMounted'

export interface UseDebouncedCallbackOptions {
wait: number
leading?: boolean
trailing?: boolean
maxWait?: number
}

/**
* Creates a debounced function that will invoke the input function after the
* specified delay.
* specified wait.
*
* @param fn a function that will be debounced
* @param delay The milliseconds delay before invoking the function
* @param waitOrOptions a wait in milliseconds or a debounce configuration
*/
export default function useDebouncedCallback<
TCallback extends (...args: any[]) => any
>(fn: TCallback, delay: number): (...args: Parameters<TCallback>) => void {
TCallback extends (...args: any[]) => any,
>(
fn: TCallback,
waitOrOptions: number | UseDebouncedCallbackOptions,
): (...args: Parameters<TCallback>) => void {
const lastCallTimeRef = useRef<number | null>(null)
const lastInvokeTimeRef = useRef(0)

const isTimerSetRef = useRef(false)
const lastArgsRef = useRef<unknown[] | null>(null)

const {
wait,
maxWait,
leading = false,
trailing = true,
} = typeof waitOrOptions === 'number'
? ({ wait: waitOrOptions } as UseDebouncedCallbackOptions)
: waitOrOptions

const timeout = useTimeout()
return useCallback(
(...args: any[]) => {
timeout.set(() => {
fn(...args)
}, delay)
},
[fn, delay],
)

return useMemo(() => {
const hasMaxWait = !!maxWait

function leadingEdge(time: number) {
// Reset any `maxWait` timer.
lastInvokeTimeRef.current = time

// Start the timer for the trailing edge.
isTimerSetRef.current = true
timeout.set(timerExpired, wait)

// Invoke the leading edge.
if (leading) {
invokeFunc(time)
}
}

function trailingEdge(time: number) {
isTimerSetRef.current = false

// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if (trailing && lastArgsRef.current) {
return invokeFunc(time)
}

lastArgsRef.current = null
}

function timerExpired() {
var time = Date.now()

if (shouldInvoke(time)) {
return trailingEdge(time)
}

const timeSinceLastCall = time - (lastCallTimeRef.current ?? 0)
const timeSinceLastInvoke = time - lastInvokeTimeRef.current
const timeWaiting = wait - timeSinceLastCall

// Restart the timer.
timeout.set(
timerExpired,
hasMaxWait
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting,
)
}

function invokeFunc(time: number) {
const args = lastArgsRef.current ?? []

lastArgsRef.current = null
lastInvokeTimeRef.current = time

return fn(...args)
}

function shouldInvoke(time: number) {
const timeSinceLastCall = time - (lastCallTimeRef.current ?? 0)
const timeSinceLastInvoke = time - lastInvokeTimeRef.current

// Either this is the first call, activity has stopped and we're at the
// trailing edge, the system time has gone backwards and we're treating
// it as the trailing edge, or we've hit the `maxWait` limit.
return (
lastCallTimeRef.current === null ||
timeSinceLastCall >= wait ||
timeSinceLastCall < 0 ||
(hasMaxWait && timeSinceLastInvoke >= maxWait)
)
}

return (...args: any[]) => {
const time = Date.now()
const isInvoking = shouldInvoke(time)

lastArgsRef.current = args
lastCallTimeRef.current = time

if (isInvoking) {
if (!isTimerSetRef.current) {
return leadingEdge(lastCallTimeRef.current)
}

if (hasMaxWait) {
// Handle invocations in a tight loop.
isTimerSetRef.current = true
setTimeout(timerExpired, wait)
return invokeFunc(lastCallTimeRef.current)
}
}

if (!isTimerSetRef.current) {
isTimerSetRef.current = true
setTimeout(timerExpired, wait)
}
}
}, [fn, wait, maxWait, leading, trailing])
}
10 changes: 6 additions & 4 deletions src/useDebouncedState.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState, Dispatch, SetStateAction } from 'react'
import useDebouncedCallback from './useDebouncedCallback'
import useDebouncedCallback, {
UseDebouncedCallbackOptions,
} from './useDebouncedCallback'

/**
* Similar to `useState`, except the setter function is debounced by
Expand All @@ -12,16 +14,16 @@ import useDebouncedCallback from './useDebouncedCallback'
* ```
*
* @param initialState initial state value
* @param delay The milliseconds delay before a new value is set
* @param delayOrOptions The milliseconds delay before a new value is set, or options object
*/
export default function useDebouncedState<T>(
initialState: T,
delay: number,
delayOrOptions: number | UseDebouncedCallbackOptions,
): [T, Dispatch<SetStateAction<T>>] {
const [state, setState] = useState(initialState)
const debouncedSetState = useDebouncedCallback<Dispatch<SetStateAction<T>>>(
setState,
delay,
delayOrOptions,
)
return [state, debouncedSetState]
}
36 changes: 29 additions & 7 deletions src/useDebouncedValue.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,46 @@
import { delay } from 'lodash'
import { useEffect, useDebugValue } from 'react'
import { useEffect, useDebugValue, useRef } from 'react'
import useDebouncedState from './useDebouncedState'
import { UseDebouncedCallbackOptions } from './useDebouncedCallback'

const defaultIsEqual = (a: any, b: any) => a === b

export type UseDebouncedValueOptions = UseDebouncedCallbackOptions & {
isEqual?: (a: any, b: any) => boolean
}

/**
* Debounce a value change by a specified number of milliseconds. Useful
* when you want need to trigger a change based on a value change, but want
* to defer changes until the changes reach some level of infrequency.
*
* @param value
* @param delayMs
* @param waitOrOptions
* @returns
*/
function useDebouncedValue<TValue>(value: TValue, delayMs = 500): TValue {
const [debouncedValue, setDebouncedValue] = useDebouncedState(value, delayMs)
function useDebouncedValue<TValue>(
value: TValue,
waitOrOptions: number | UseDebouncedValueOptions = 500,
): TValue {
const previousValueRef = useRef<TValue | null>(value)

const isEqual =
typeof waitOrOptions === 'object'
? waitOrOptions.isEqual || defaultIsEqual
: defaultIsEqual

const [debouncedValue, setDebouncedValue] = useDebouncedState(
value,
waitOrOptions,
)

useDebugValue(debouncedValue)

useEffect(() => {
setDebouncedValue(value)
}, [value, delayMs])
if (!isEqual || !isEqual(previousValueRef.current, value)) {
previousValueRef.current = value
setDebouncedValue(value)
}
})

return debouncedValue
}
Expand Down
1 change: 1 addition & 0 deletions src/useTimeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default function useTimeout() {
return {
set,
clear,
handleRef,
}
}, [])
}
Loading

0 comments on commit c8d69b2

Please sign in to comment.