2
2
*@file Defines hooks for created debounced versions of functions and arbitrary
3
3
* values.
4
4
*
5
- * It is not safe to call a general-purpose debounce utility inside a React
6
- * render. It will work on the initial render, but the memory reference for the
7
- * value will change on re-renders. Most debounce functions create a "stateful"
8
- * version of a function by leveraging closure; but by calling it repeatedly,
9
- * you create multiple "pockets" of state, rather than a centralized one.
10
- *
11
- * Debounce utilities can make sense if they can be called directly outside the
12
- * component or in a useEffect call, though.
5
+ * It is not safe to call most general-purpose debounce utility functions inside
6
+ * a React render. This is because the state for handling the debounce logic
7
+ * lives in the utility instead of React. If you call a general-purpose debounce
8
+ * function inline, that will create a new stateful function on every render,
9
+ * which has a lot of risks around conflicting/contradictory state.
13
10
*/
14
11
import { useCallback , useEffect , useRef , useState } from "react" ;
15
12
16
- type useDebouncedFunctionReturn < Args extends unknown [ ] > = Readonly < {
13
+ type UseDebouncedFunctionReturn < Args extends unknown [ ] > = Readonly < {
17
14
debounced :( ...args :Args ) => void ;
18
15
19
16
// Mainly here to make interfacing with useEffect cleanup functions easier
@@ -34,26 +31,32 @@ type useDebouncedFunctionReturn<Args extends unknown[]> = Readonly<{
34
31
*/
35
32
export function useDebouncedFunction <
36
33
// Parameterizing on the args instead of the whole callback function type to
37
- // avoid typecontra-variance issues
34
+ // avoid typecontravariance issues
38
35
Args extends unknown [ ] = unknown [ ] ,
39
36
> (
40
37
callback :( ...args :Args ) => void | Promise < void > ,
41
- debounceTimeMs :number ,
42
- ) :useDebouncedFunctionReturn < Args > {
43
- const timeoutIdRef = useRef < number | null > ( null ) ;
38
+ debounceTimeoutMs :number ,
39
+ ) :UseDebouncedFunctionReturn < Args > {
40
+ if ( ! Number . isInteger ( debounceTimeoutMs ) || debounceTimeoutMs < 0 ) {
41
+ throw new Error (
42
+ `Invalid value${ debounceTimeoutMs } for debounceTimeoutMs. Value must be an integer greater than or equal to zero.` ,
43
+ ) ;
44
+ }
45
+
46
+ const timeoutIdRef = useRef < number | undefined > ( undefined ) ;
44
47
const cancelDebounce = useCallback ( ( ) => {
45
- if ( timeoutIdRef . current !== null ) {
48
+ if ( timeoutIdRef . current !== undefined ) {
46
49
window . clearTimeout ( timeoutIdRef . current ) ;
47
50
}
48
51
49
- timeoutIdRef . current = null ;
52
+ timeoutIdRef . current = undefined ;
50
53
} , [ ] ) ;
51
54
52
- const debounceTimeRef = useRef ( debounceTimeMs ) ;
55
+ const debounceTimeRef = useRef ( debounceTimeoutMs ) ;
53
56
useEffect ( ( ) => {
54
57
cancelDebounce ( ) ;
55
- debounceTimeRef . current = debounceTimeMs ;
56
- } , [ cancelDebounce , debounceTimeMs ] ) ;
58
+ debounceTimeRef . current = debounceTimeoutMs ;
59
+ } , [ cancelDebounce , debounceTimeoutMs ] ) ;
57
60
58
61
const callbackRef = useRef ( callback ) ;
59
62
useEffect ( ( ) => {
@@ -81,19 +84,32 @@ export function useDebouncedFunction<
81
84
/**
82
85
* Takes any value, and returns out a debounced version of it.
83
86
*/
84
- export function useDebouncedValue < T = unknown > (
85
- value :T ,
86
- debounceTimeMs :number ,
87
- ) :T {
87
+ export function useDebouncedValue < T > ( value :T , debounceTimeoutMs :number ) :T {
88
+ if ( ! Number . isInteger ( debounceTimeoutMs ) || debounceTimeoutMs < 0 ) {
89
+ throw new Error (
90
+ `Invalid value${ debounceTimeoutMs } for debounceTimeoutMs. Value must be an integer greater than or equal to zero.` ,
91
+ ) ;
92
+ }
93
+
88
94
const [ debouncedValue , setDebouncedValue ] = useState ( value ) ;
89
95
96
+ // If the debounce timeout is ever zero, synchronously flush any state syncs.
97
+ // Doing this mid-render instead of in useEffect means that we drastically cut
98
+ // down on needless re-renders, and we also avoid going through the event loop
99
+ // to do a state sync that is *intended* to happen immediately
100
+ if ( value !== debouncedValue && debounceTimeoutMs === 0 ) {
101
+ setDebouncedValue ( value ) ;
102
+ }
90
103
useEffect ( ( ) => {
104
+ if ( debounceTimeoutMs === 0 ) {
105
+ return ;
106
+ }
107
+
91
108
const timeoutId = window . setTimeout ( ( ) => {
92
109
setDebouncedValue ( value ) ;
93
- } , debounceTimeMs ) ;
94
-
110
+ } , debounceTimeoutMs ) ;
95
111
return ( ) => window . clearTimeout ( timeoutId ) ;
96
- } , [ value , debounceTimeMs ] ) ;
112
+ } , [ value , debounceTimeoutMs ] ) ;
97
113
98
114
return debouncedValue ;
99
115
}