Skip to content

Instantly share code, notes, and snippets.

@denk0403
Last active September 6, 2025 22:28
Show Gist options
  • Save denk0403/61ee2610fefe695a89f48d25aa49eac0 to your computer and use it in GitHub Desktop.
Save denk0403/61ee2610fefe695a89f48d25aa49eac0 to your computer and use it in GitHub Desktop.
usePendingDebounce() - A hook for debouncing React actions
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