Building debounce
from scratch
Type fast and watch what happens. The raw handler fires on every keystroke. The debounced version waits until you stop typing. Same input, dramatically different behavior — and it's only 20 lines of code.
Type something and watch the timeline
Every keystroke fires the raw handler immediately. The debounced version only fires once you pause for 300ms. Try typing fast, then slow, and see how the calls differ.
20 lines that save thousands of API calls
A closure, a timer, and clearTimeout. That's the entire trick. The generic typing and cancel method are what make it production-grade.
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number) {
let timerId: ReturnType<typeof setTimeout> | null = null;
function cancel() {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
}
const debouncedFn = function (this: any, ...args: Parameters<T>) {
cancel();
timerId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
debouncedFn.cancel = function () {
cancel();
};
return debouncedFn;
}The timerId variable is trapped in a closure — it persists across calls but is invisible from outside. Each invocation of the debounced function reads and mutates the same timer. This is closures doing real work, not just an interview trivia question.
Don't create the debounced function inside a component render — it gets recreated every render, losing the timer state. Use useRef or useMemo to keep a stable reference. And always call cancel() in your cleanup function.