22 * @file Defines hooks for created debounced versions of functions and arbitrary
33 * values.
44 *
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.
1310 */
1411import { useCallback , useEffect , useRef , useState } from "react" ;
1512
16- type useDebouncedFunctionReturn < Args extends unknown [ ] > = Readonly < {
13+ type UseDebouncedFunctionReturn < Args extends unknown [ ] > = Readonly < {
1714 debounced : ( ...args : Args ) => void ;
1815
1916 // Mainly here to make interfacing with useEffect cleanup functions easier
@@ -34,26 +31,32 @@ type useDebouncedFunctionReturn<Args extends unknown[]> = Readonly<{
3431 */
3532export function useDebouncedFunction <
3633 // Parameterizing on the args instead of the whole callback function type to
37- // avoid type contra-variance issues
34+ // avoid type contravariance issues
3835 Args extends unknown [ ] = unknown [ ] ,
3936> (
4037 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 ) ;
4447 const cancelDebounce = useCallback ( ( ) => {
45- if ( timeoutIdRef . current !== null ) {
48+ if ( timeoutIdRef . current !== undefined ) {
4649 window . clearTimeout ( timeoutIdRef . current ) ;
4750 }
4851
49- timeoutIdRef . current = null ;
52+ timeoutIdRef . current = undefined ;
5053 } , [ ] ) ;
5154
52- const debounceTimeRef = useRef ( debounceTimeMs ) ;
55+ const debounceTimeRef = useRef ( debounceTimeoutMs ) ;
5356 useEffect ( ( ) => {
5457 cancelDebounce ( ) ;
55- debounceTimeRef . current = debounceTimeMs ;
56- } , [ cancelDebounce , debounceTimeMs ] ) ;
58+ debounceTimeRef . current = debounceTimeoutMs ;
59+ } , [ cancelDebounce , debounceTimeoutMs ] ) ;
5760
5861 const callbackRef = useRef ( callback ) ;
5962 useEffect ( ( ) => {
@@ -81,19 +84,32 @@ export function useDebouncedFunction<
8184/**
8285 * Takes any value, and returns out a debounced version of it.
8386 */
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+
8894 const [ debouncedValue , setDebouncedValue ] = useState ( value ) ;
8995
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+ }
90103 useEffect ( ( ) => {
104+ if ( debounceTimeoutMs === 0 ) {
105+ return ;
106+ }
107+
91108 const timeoutId = window . setTimeout ( ( ) => {
92109 setDebouncedValue ( value ) ;
93- } , debounceTimeMs ) ;
94-
110+ } , debounceTimeoutMs ) ;
95111 return ( ) => window . clearTimeout ( timeoutId ) ;
96- } , [ value , debounceTimeMs ] ) ;
112+ } , [ value , debounceTimeoutMs ] ) ;
97113
98114 return debouncedValue ;
99115}
0 commit comments