Skip to content

Instantly share code, notes, and snippets.

@librz
Created June 30, 2026 08:34
Show Gist options
  • Select an option

  • Save librz/34782fe034820316712121891a195717 to your computer and use it in GitHub Desktop.

Select an option

Save librz/34782fe034820316712121891a195717 to your computer and use it in GitHub Desktop.
Primitives for debounce controlled input
import { useCallback, useEffect, useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
export type UseDebouncedInputOptions = {
inputDebounced: string;
onChangeDebounced: (value: string) => void;
debounceMs?: number;
};
/**
* Hook for debouncing **controlled** input
*
* debounce an uncontrolled input is simple: just debounce the onChange callback; To debounce controlled input is tricky:
* 1. must separate input text state from debounced state & implement syncing;
* 2. must cancel pending callback if and only if controlled value is changed from outside (e.g. a reset button); So we must distinguish internal updates from external updates
* 3. consumer must consider limiting the scope of input text state as it can update at high frequency (typing speed)
*/
export function useDebouncedInput({
inputDebounced,
onChangeDebounced,
debounceMs = 500,
}: UseDebouncedInputOptions) {
const [inputValue, setInputValueState] = useState(inputDebounced);
// Value most recently emitted through `onChangeDebounced`. Used to tell apart
// a parent echo of our own callback from an external controlled-value change,
// even if the user has typed additional characters since the callback fired.
const emittedValueRef = useRef<string | null>(null);
const triggerDebouncedChange = useDebouncedCallback((nextValue: string) => {
emittedValueRef.current = nextValue;
onChangeDebounced(nextValue);
}, debounceMs);
useEffect(() => {
if (inputDebounced === emittedValueRef.current) {
// Internal change: the parent passed back the value from our last emitted
// callback. Do not overwrite input while the user is typing.
emittedValueRef.current = null;
return;
}
// External change (e.g. reset). Sync state and cancel any pending debounce so an
// earlier typed value does not overwrite the reset.
emittedValueRef.current = null;
setInputValueState(inputDebounced);
triggerDebouncedChange.cancel();
}, [inputDebounced, triggerDebouncedChange]);
const setInputValue = useCallback(
(nextValue: string) => {
setInputValueState(nextValue);
triggerDebouncedChange(nextValue);
},
[triggerDebouncedChange],
);
return { inputValue, setInputValue };
}
/**
* provides debounce service for controlled input
* @description just a thin layer on top of useDebouncedInput. The provider serves as a state container for local input state. The input state is controlled and can update at considerable frequency (typing speed) so we need to keep it scoped
*/
export function DebouncedInputProvider(
props: {
children: (args: ReturnType<typeof useDebouncedInput>) => ReactNode;
} & UseDebouncedInputOptions,
) {
const { children, ...options } = props;
const result = useDebouncedInput(options);
return children(result);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment