跪拜 Guibai
← Back to the summary

The Three Hidden Bugs Lurking in Your Debounce Hook for Two Years

Debounce and throttle are among the most fundamental concepts in frontend development. It's easy to copy a useDebounce or useThrottle from the internet, drop it into a project, and have it run without major issues for two years.

That is, until I recently investigated a strange memory leak warning. I dug out the Hook I'd been using for two years and took a fresh look—and found three hidden bugs inside. They normally never trigger, but when they do, they cause production incidents.

The Version I Wrote Two Years Ago

// The version from two years ago. It runs, but it has problems.
function useDebounce(callback, delay) {
  const timerRef = useRef(null);

  const debouncedFn = (...args) => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    timerRef.current = setTimeout(() => {
      callback(...args);
    }, delay);
  };

  return debouncedFn;
}

Usage:

function SearchBox() {
  const [keyword, setKeyword] = useState('');

  const search = useDebounce((value) => {
    fetchSearchResults(value).then(setResults);
  }, 300);

  return <input onChange={(e) => search(e.target.value)} />;
}

This code works well in most scenarios. But it hides three problems.

Hidden Bug 1: Callback Executes After Component Unmount

function SearchBox() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);

  const search = useDebounce((value) => {
    fetchSearchResults(value).then((data) => {
      setResults(data); // The component may already be unmounted!
    });
  }, 300);

  return <input onChange={(e) => search(e.target.value)} />;
}

Trigger scenario: The user types a keyword and immediately navigates to another page. After 300ms, the debounced callback fires, and setResults executes on an already unmounted component—React will warn "Cannot update state on an unmounted component" in the console. While it won't crash outright, if the callback contains other side effects (like writing to localStorage or sending analytics events), real problems can occur.

// ✅ Clean up the timer when the component unmounts
function useDebounce(callback, delay) {
  const timerRef = useRef(null);

  useEffect(() => {
    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }
    };
  }, []);

  const debouncedFn = (...args) => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    timerRef.current = setTimeout(() => {
      callback(...args);
    }, delay);
  };

  return debouncedFn;
}

Principle: Every Hook that uses setTimeout/setInterval must clear the timer inside the useEffect cleanup function. This is not optional; it is mandatory.

Hidden Bug 2: The Callback Inside the Closure Is Stale

This is the most insidious one. First, a reproduction scenario:

function SearchBox() {
  const [keyword, setKeyword] = useState('');
  const [category, setCategory] = useState('all');

  // When category changes, the search function should get the latest category
  const search = useDebounce((value) => {
    fetchSearchResults(value, category).then(setResults);
    // The category here is always the value from when the Hook was first created!
  }, 300);

  return (
    <div>
      <select onChange={(e) => setCategory(e.target.value)}>
        <option value="all">All</option>
        <option value="books">Books</option>
      </select>
      <input onChange={(e) => search(e.target.value)} />
    </div>
  );
}

Why? useDebounce creates the debouncedFn function only on the component's first render (if there is no dependency array controlling it). The closure inside this function captures the callback that was passed in during that first render. Even if category changes later and the component re-renders, the callback still references the old category.

This is the classic stale closure problem. The user clearly switches the category, but the search request still carries the old category—a very subtle bug, because most of the time during testing you won't happen to switch filters precisely during the debounce wait period.

// ✅ Use a ref to store the latest callback and avoid stale closures
function useDebounce(callback, delay) {
  const timerRef = useRef(null);
  const callbackRef = useRef(callback);

  // Update the ref on every render to ensure we always get the latest callback
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, []);

  const debouncedFn = useCallback((...args) => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      callbackRef.current(...args); // Always gets the latest callback
    }, delay);
  }, [delay]);

  return debouncedFn;
}

Principle: If a Hook needs to hold onto an externally passed function long-term, use a ref to store the latest value. Do not assume closures will automatically update.

This is also why the dependency array of useEffect is so important—it is precisely there to remind you that "closures can go stale."

Hidden Bug 3: No Cancel Method, No Way to Actively Interrupt

function SearchBox() {
  const search = useDebounce((value) => {
    fetchSearchResults(value).then(setResults);
  }, 300);

  const handleClear = () => {
    setKeyword('');
    setResults([]);
    // But the previously queued search call will still execute after 300ms!
    // 0.3 seconds after clearing, the search results pop back up.
  };

  return (
    <div>
      <input onChange={(e) => search(e.target.value)} />
      <button onClick={handleClear}>Clear</button>
    </div>
  );
}

Trigger scenario: The user types a keyword, and the debounce timer is running. Then the user clicks the "Clear" button. The UI indeed clears the results—but 0.3 seconds later, the previously queued debounced callback fires, and the search results pop back up, overwriting the cleared state. The user sees a bizarre bug where content reappears on its own after being cleared.

Our useDebounce only provides a trigger method; it does not provide a cancel method. This is a missing feature, not a logic error, but it still causes a real user experience problem.

// ✅ Provide a cancel method so external code can actively interrupt
function useDebounce(callback, delay) {
  const timerRef = useRef(null);
  const callbackRef = useRef(callback);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, []);

  const debouncedFn = useCallback((...args) => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      callbackRef.current(...args);
    }, delay);
  }, [delay]);

  const cancel = useCallback(() => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  return [debouncedFn, cancel];
}
// Usage: get the cancel method and call it wherever interruption is needed
function SearchBox() {
  const [search, cancelSearch] = useDebounce((value) => {
    fetchSearchResults(value).then(setResults);
  }, 300);

  const handleClear = () => {
    setKeyword('');
    setResults([]);
    cancelSearch(); // Actively interrupt the queued call
  };

  return (
    <div>
      <input onChange={(e) => search(e.target.value)} />
      <button onClick={handleClear}>Clear</button>
    </div>
  );
}

Principle: Any "delayed execution" mechanism must be paired with a "cancel execution" exit. A debounce/throttle without a cancel method is only half-finished.

Throttle Has the Same Problems

Applying the above reasoning to throttle, the same three bugs appear in different forms:

// ✅ Complete version of useThrottle, handling the same three problems
function useThrottle(callback, delay) {
  const timerRef = useRef(null);
  const lastRunRef = useRef(0);
  const callbackRef = useRef(callback);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, []);

  const throttledFn = useCallback((...args) => {
    const now = Date.now();
    const remaining = delay - (now - lastRunRef.current);

    if (remaining <= 0) {
      lastRunRef.current = now;
      callbackRef.current(...args);
    } else if (!timerRef.current) {
      timerRef.current = setTimeout(() => {
        lastRunRef.current = Date.now();
        timerRef.current = null;
        callbackRef.current(...args);
      }, remaining);
    }
  }, [delay]);

  const cancel = useCallback(() => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  return [throttledFn, cancel];
}

Throttle has one more detail than debounce: whether to use "leading" (execute immediately on the first trigger) or "trailing" (execute once more after the last trigger). These two semantics are completely different in scenarios like scroll listening versus button double-click prevention. You must be clear about which one you need before using it.

Quick Reference Table

Hidden Bug Trigger Scenario Fix
Executes after unmount Timer still running when component unmounts clearTimeout inside useEffect cleanup function
Stale closure Dependent external variables change but don't take effect Use ref to store the latest callback
Cannot cancel Need to actively interrupt a queued call Expose a cancel method

Two Years Without Problems ≠ No Bugs

The reason these three bugs lurked in production for two years without being discovered is that their trigger conditions are all quite tricky: race conditions around unmount timing, the timing of dependency changes, and interactions that require active interruption. Daily testing rarely covers these.

But as long as a project runs long enough and has a large enough user base, these edge cases will eventually be hit.

Utility functions—the kind of code you copy once and never look at again—are actually the easiest places to bury landmines, because no one proactively re-examines them when "it's always worked fine."

Does the debounce/throttle Hook in your project have a cancel method? Let's discuss in the comments.