Skip to content

Commit c8d69b2

Browse files
authored
feat: add leading, and maxWait options to debounce hooks (#97)
1 parent 2464d25 commit c8d69b2

File tree

6 files changed

+417
-31
lines changed

6 files changed

+417
-31
lines changed

src/useDebouncedCallback.ts

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,141 @@
1-
import { useCallback } from 'react'
1+
import { useCallback, useMemo, useRef } from 'react'
22
import useTimeout from './useTimeout'
3+
import useMounted from './useMounted'
4+
5+
export interface UseDebouncedCallbackOptions {
6+
wait: number
7+
leading?: boolean
8+
trailing?: boolean
9+
maxWait?: number
10+
}
311

412
/**
513
* Creates a debounced function that will invoke the input function after the
6-
* specified delay.
14+
* specified wait.
715
*
816
* @param fn a function that will be debounced
9-
* @param delay The milliseconds delay before invoking the function
17+
* @param waitOrOptions a wait in milliseconds or a debounce configuration
1018
*/
1119
export default function useDebouncedCallback<
12-
TCallback extends (...args: any[]) => any
13-
>(fn: TCallback, delay: number): (...args: Parameters<TCallback>) => void {
20+
TCallback extends (...args: any[]) => any,
21+
>(
22+
fn: TCallback,
23+
waitOrOptions: number | UseDebouncedCallbackOptions,
24+
): (...args: Parameters<TCallback>) => void {
25+
const lastCallTimeRef = useRef<number | null>(null)
26+
const lastInvokeTimeRef = useRef(0)
27+
28+
const isTimerSetRef = useRef(false)
29+
const lastArgsRef = useRef<unknown[] | null>(null)
30+
31+
const {
32+
wait,
33+
maxWait,
34+
leading = false,
35+
trailing = true,
36+
} = typeof waitOrOptions === 'number'
37+
? ({ wait: waitOrOptions } as UseDebouncedCallbackOptions)
38+
: waitOrOptions
39+
1440
const timeout = useTimeout()
15-
return useCallback(
16-
(...args: any[]) => {
17-
timeout.set(() => {
18-
fn(...args)
19-
}, delay)
20-
},
21-
[fn, delay],
22-
)
41+
42+
return useMemo(() => {
43+
const hasMaxWait = !!maxWait
44+
45+
function leadingEdge(time: number) {
46+
// Reset any `maxWait` timer.
47+
lastInvokeTimeRef.current = time
48+
49+
// Start the timer for the trailing edge.
50+
isTimerSetRef.current = true
51+
timeout.set(timerExpired, wait)
52+
53+
// Invoke the leading edge.
54+
if (leading) {
55+
invokeFunc(time)
56+
}
57+
}
58+
59+
function trailingEdge(time: number) {
60+
isTimerSetRef.current = false
61+
62+
// Only invoke if we have `lastArgs` which means `func` has been
63+
// debounced at least once.
64+
if (trailing && lastArgsRef.current) {
65+
return invokeFunc(time)
66+
}
67+
68+
lastArgsRef.current = null
69+
}
70+
71+
function timerExpired() {
72+
var time = Date.now()
73+
74+
if (shouldInvoke(time)) {
75+
return trailingEdge(time)
76+
}
77+
78+
const timeSinceLastCall = time - (lastCallTimeRef.current ?? 0)
79+
const timeSinceLastInvoke = time - lastInvokeTimeRef.current
80+
const timeWaiting = wait - timeSinceLastCall
81+
82+
// Restart the timer.
83+
timeout.set(
84+
timerExpired,
85+
hasMaxWait
86+
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
87+
: timeWaiting,
88+
)
89+
}
90+
91+
function invokeFunc(time: number) {
92+
const args = lastArgsRef.current ?? []
93+
94+
lastArgsRef.current = null
95+
lastInvokeTimeRef.current = time
96+
97+
return fn(...args)
98+
}
99+
100+
function shouldInvoke(time: number) {
101+
const timeSinceLastCall = time - (lastCallTimeRef.current ?? 0)
102+
const timeSinceLastInvoke = time - lastInvokeTimeRef.current
103+
104+
// Either this is the first call, activity has stopped and we're at the
105+
// trailing edge, the system time has gone backwards and we're treating
106+
// it as the trailing edge, or we've hit the `maxWait` limit.
107+
return (
108+
lastCallTimeRef.current === null ||
109+
timeSinceLastCall >= wait ||
110+
timeSinceLastCall < 0 ||
111+
(hasMaxWait && timeSinceLastInvoke >= maxWait)
112+
)
113+
}
114+
115+
return (...args: any[]) => {
116+
const time = Date.now()
117+
const isInvoking = shouldInvoke(time)
118+
119+
lastArgsRef.current = args
120+
lastCallTimeRef.current = time
121+
122+
if (isInvoking) {
123+
if (!isTimerSetRef.current) {
124+
return leadingEdge(lastCallTimeRef.current)
125+
}
126+
127+
if (hasMaxWait) {
128+
// Handle invocations in a tight loop.
129+
isTimerSetRef.current = true
130+
setTimeout(timerExpired, wait)
131+
return invokeFunc(lastCallTimeRef.current)
132+
}
133+
}
134+
135+
if (!isTimerSetRef.current) {
136+
isTimerSetRef.current = true
137+
setTimeout(timerExpired, wait)
138+
}
139+
}
140+
}, [fn, wait, maxWait, leading, trailing])
23141
}

src/useDebouncedState.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useState, Dispatch, SetStateAction } from 'react'
2-
import useDebouncedCallback from './useDebouncedCallback'
2+
import useDebouncedCallback, {
3+
UseDebouncedCallbackOptions,
4+
} from './useDebouncedCallback'
35

46
/**
57
* Similar to `useState`, except the setter function is debounced by
@@ -12,16 +14,16 @@ import useDebouncedCallback from './useDebouncedCallback'
1214
* ```
1315
*
1416
* @param initialState initial state value
15-
* @param delay The milliseconds delay before a new value is set
17+
* @param delayOrOptions The milliseconds delay before a new value is set, or options object
1618
*/
1719
export default function useDebouncedState<T>(
1820
initialState: T,
19-
delay: number,
21+
delayOrOptions: number | UseDebouncedCallbackOptions,
2022
): [T, Dispatch<SetStateAction<T>>] {
2123
const [state, setState] = useState(initialState)
2224
const debouncedSetState = useDebouncedCallback<Dispatch<SetStateAction<T>>>(
2325
setState,
24-
delay,
26+
delayOrOptions,
2527
)
2628
return [state, debouncedSetState]
2729
}

src/useDebouncedValue.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,46 @@
1-
import { delay } from 'lodash'
2-
import { useEffect, useDebugValue } from 'react'
1+
import { useEffect, useDebugValue, useRef } from 'react'
32
import useDebouncedState from './useDebouncedState'
3+
import { UseDebouncedCallbackOptions } from './useDebouncedCallback'
4+
5+
const defaultIsEqual = (a: any, b: any) => a === b
6+
7+
export type UseDebouncedValueOptions = UseDebouncedCallbackOptions & {
8+
isEqual?: (a: any, b: any) => boolean
9+
}
410

511
/**
612
* Debounce a value change by a specified number of milliseconds. Useful
713
* when you want need to trigger a change based on a value change, but want
814
* to defer changes until the changes reach some level of infrequency.
915
*
1016
* @param value
11-
* @param delayMs
17+
* @param waitOrOptions
1218
* @returns
1319
*/
14-
function useDebouncedValue<TValue>(value: TValue, delayMs = 500): TValue {
15-
const [debouncedValue, setDebouncedValue] = useDebouncedState(value, delayMs)
20+
function useDebouncedValue<TValue>(
21+
value: TValue,
22+
waitOrOptions: number | UseDebouncedValueOptions = 500,
23+
): TValue {
24+
const previousValueRef = useRef<TValue | null>(value)
25+
26+
const isEqual =
27+
typeof waitOrOptions === 'object'
28+
? waitOrOptions.isEqual || defaultIsEqual
29+
: defaultIsEqual
30+
31+
const [debouncedValue, setDebouncedValue] = useDebouncedState(
32+
value,
33+
waitOrOptions,
34+
)
1635

1736
useDebugValue(debouncedValue)
1837

1938
useEffect(() => {
20-
setDebouncedValue(value)
21-
}, [value, delayMs])
39+
if (!isEqual || !isEqual(previousValueRef.current, value)) {
40+
previousValueRef.current = value
41+
setDebouncedValue(value)
42+
}
43+
})
2244

2345
return debouncedValue
2446
}

src/useTimeout.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default function useTimeout() {
7373
return {
7474
set,
7575
clear,
76+
handleRef,
7677
}
7778
}, [])
7879
}

0 commit comments

Comments
 (0)