Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add leading, and maxWait options to debounce hooks #97

Merged
merged 1 commit into from
Dec 6, 2023
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
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,

Check warning on line 87 in src/useDebouncedCallback.ts

View check run for this annotation

Codecov / codecov/patch

src/useDebouncedCallback.ts#L87

Added line #L87 was not covered by tests
)
}

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)

Check warning on line 131 in src/useDebouncedCallback.ts

View check run for this annotation

Codecov / codecov/patch

src/useDebouncedCallback.ts#L129-L131

Added lines #L129 - L131 were not covered by tests
}
}

if (!isTimerSetRef.current) {
isTimerSetRef.current = true
setTimeout(timerExpired, wait)

Check warning on line 137 in src/useDebouncedCallback.ts

View check run for this annotation

Codecov / codecov/patch

src/useDebouncedCallback.ts#L136-L137

Added lines #L136 - L137 were not covered by tests
}
}
}, [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
Loading