Last active
September 6, 2025 22:28
-
-
Save denk0403/61ee2610fefe695a89f48d25aa49eac0 to your computer and use it in GitHub Desktop.
usePendingDebounce() - A hook for debouncing React actions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useCallback, useRef, useTransition } from 'react'; | |
| /** Represents a function with a `setTimeout`-like signature. */ | |
| type SetTimeoutLikeFn<T = unknown> = ( | |
| delayInMs: number, | |
| callbackFn: () => T | Promise<T> | |
| ) => void; | |
| /** | |
| * A utility hook for debouncing synchronous or asynchronous callbacks. | |
| * This hook provides a `setTimeout`-like function that automatically | |
| * cancels the previous scheduled callback if it hasn't fired yet | |
| * (a.k.a "debouncing"). This hook also provides a boolean that indicates | |
| * whether the conjoined operation is currently pending. | |
| * | |
| * This hook is integrated with React 19 "actions", which means the callback can | |
| * use `startTransition` to defer UI updates until all pending operations have settled. | |
| * | |
| * **Important Note**: The operation begins pending as soon as a callback is scheduled, | |
| * _not_ when the callback is executed. And it will remain pending at least until the | |
| * callback has settled. However, if a new callback is scheduled after the "waiting period" | |
| * but before the previous callback has settled (i.e. it's too late to cancel the previous | |
| * callback), then the operation will remain in a pending state until it has also settled. | |
| * | |
| * Note: The returned function can only "debounce" one callback at | |
| * a time. As a result, if you require the ability to debounce multiple | |
| * callbacks simultaneously, use multiple instances of this hook. | |
| * | |
| * @returns A tuple containing a `setTimeout`-like function and a boolean indicating | |
| * whether the operation is currently pending. | |
| * @see {@link https://react.dev/reference/react/startTransition startTransition} | |
| * @example | |
| * const [debounce, isPending] = usePendingDebounce(); | |
| * const [error, setError] = useState(null); | |
| * // later in some UI... | |
| * <input onInput={(e) => { | |
| * setError(null); | |
| * debounce(500, async () => { | |
| * try { | |
| * await updateUserData(e.currentTarget.value) | |
| * startTransition(() => setError(null)); | |
| * } catch (err) { | |
| * startTransition(() => setError(err)); | |
| * } | |
| * }); | |
| * }} /> | |
| * {error && <p>{error.message}</p>} | |
| * <button disabled={isPending || !!error}>Continue</button> | |
| */ | |
| export function usePendingDebounce(): [SetTimeoutLikeFn, isPending: boolean] { | |
| const timeoutIDRef = useRef<number>(undefined); | |
| const [isPending, startTransition] = useTransition(); | |
| const debounce: SetTimeoutLikeFn = useCallback( | |
| (delayInMs, callbackFn) => { | |
| clearTimeout(timeoutIDRef.current); // clear last timeout to debounce callback | |
| /** | |
| * Call `startTransition` to immediately begin pending. | |
| * | |
| * This also enables the given callback to start its own transitions that | |
| * will be batched and deferred until all have completed, which can be used | |
| * to effectively debounce UI updates as well. | |
| */ | |
| startTransition(async () => { | |
| await new Promise((resolve, reject) => { | |
| // schedule the callback | |
| timeoutIDRef.current = window.setTimeout(() => resolve(Promise.try(callbackFn)), delayInMs); | |
| /** | |
| * If `resolve` wasn't called by the time the delay has passed, that means it | |
| * was debounced with a new call. Therefore, we should reject the promise to | |
| * clean up the pending state. | |
| * | |
| * **Note**: Once a promise is resolved or rejected, it cannot be resolved | |
| * or rejected again. That is why it's okay for this timeout to always run. | |
| */ | |
| window.setTimeout(() => reject(), delayInMs); | |
| }).catch(() => {}); // Ignore errors to prevent displaying an error boundary. | |
| }); | |
| }, | |
| [] | |
| ); | |
| return [debounce, isPending]; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment