Implement a custom hook in React that debounces input value.
`useDebouncedValue`: returns a value that updates only after the input has stopped changing for N ms. Implement with `useState` + `useEffect` (set timer on value change, clear on cleanup). Useful for search-as-you-type. Distinct from `useDeferredValue` (React 18) which is concurrent-mode aware and yields to interaction.
useDebouncedValue
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay = 250): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t); // clear on next value / unmount
}, [value, delay]);
return debounced;
}Usage
function Search() {
const [q, setQ] = useState("");
const debouncedQ = useDebouncedValue(q, 300);
const { data } = useQuery(["search", debouncedQ], () => api.search(debouncedQ), {
enabled: !!debouncedQ,
});
return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}Input stays snappy (state updates immediately); the derived debouncedQ lags until the user stops typing.
useDebouncedCallback (lodash-style)
If you want a debounced function instead of value:
import { useEffect, useMemo, useRef } from "react";
export function useDebouncedCallback<T extends (...args: any[]) => void>(fn: T, delay = 250) {
const fnRef = useRef(fn);
useEffect(() => { fnRef.current = fn; }, [fn]); // always latest
const debounced = useMemo(() => {
let t: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(t);
t = setTimeout(() => fnRef.current(...args), delay);
};
}, [delay]);
useEffect(() => () => clearTimeout(((debounced as any).__t)), []); // cleanup
return debounced;
}The ref trick keeps the closure pointing at the latest function without recreating the debouncer per render.
vs useDeferredValue
React 18's useDeferredValue(value):
- Returns the previous value during urgent updates.
- Yields back to user input.
- No fixed delay — React decides.
| useDebouncedValue | useDeferredValue | |
|---|---|---|
| Mechanism | setTimeout | React scheduler |
| Delay | Fixed | Adaptive |
| Use case | Avoid frequent API calls | Avoid expensive re-renders |
| Cancellation | Yes (next change clears) | Built-in |
For network-bound work (search API), debounced value still wins because you want fewer calls. For render-bound work (filtering a big list), useDeferredValue is better.
Common mistakes
- Not cleaning up the timer on unmount (leak + late updates).
- Recreating the debounced function every render (resets the debouncer).
- Debouncing the input
valueitself instead of the derived consumer — typing feels laggy.
Tests
// vitest + @testing-library
import { renderHook, act } from "@testing-library/react";
test("debounces", () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(({ v }) => useDebouncedValue(v, 100), { initialProps: { v: "a" } });
rerender({ v: "ab" });
expect(result.current).toBe("a");
act(() => jest.advanceTimersByTime(100));
expect(result.current).toBe("ab");
});Interview framing
"useState + useEffect with setTimeout. On every change, set a timer; cleanup clears it; only the last quiet pulse fires. Drives search-as-you-type by feeding the debounced value into React Query / fetch. For a debounced callback I use a ref to hold the latest function so the debouncer itself isn't recreated per render. For render-bound perf (not network), useDeferredValue is the React 18 equivalent — concurrent-mode aware, no fixed delay."
Follow-up questions
- •Compare with useDeferredValue.
- •Why use a ref in useDebouncedCallback?
- •How would you test this?
Common mistakes
- •Not clearing timeout in cleanup.
- •Recreating debounce per render.
- •Debouncing the input value (laggy typing).
Performance considerations
- •Negligible cost. Saves heavy work (API calls, expensive renders) downstream.
Edge cases
- •Delay change should reset.
- •Unmount during pending timer.
- •SSR — initial value matches.
Real-world examples
- •Search bars, autosave drafts, filter input on big tables.