Created
June 30, 2026 08:34
-
-
Save librz/34782fe034820316712121891a195717 to your computer and use it in GitHub Desktop.
Primitives for debounce controlled input
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, 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