跪拜 Guibai
← All articles
Frontend · JavaScript · Interview

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

By kyriewen ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

These bugs survive code review and basic testing because they only surface under specific timing conditions—rapid navigation, filter changes during a debounce window, or clear-button interactions. Once a codebase grows large enough, those edge cases become inevitable, and the resulting state corruption or stale-data requests are notoriously hard to reproduce and debug.

Summary

A widely used `useDebounce` implementation, copied from the web and dropped into a React project, hid three distinct bugs that survived two years of production use. The first is a missing cleanup on component unmount, which lets callbacks fire after a component is gone and triggers React state-update warnings. The second is a stale closure: the callback captured on first render never updates, so any state it closes over—like a search category filter—remains frozen at its initial value. The third is the absence of a cancel method, which means a queued debounce call cannot be interrupted; clearing a search box, for example, gets overwritten 300ms later when the pending callback fires.

The fixes are straightforward but mandatory. Clean up timers in a `useEffect` return function. Store the latest callback in a `ref` and update it on every render, then call `callbackRef.current` inside the timeout. Expose a `cancel` function that clears the timer and nulls the ref. The same three bugs apply to throttle Hooks, with the added decision of whether to use leading or trailing execution semantics. Utility functions that "just work" are the easiest places to bury landmines because no one re-examines code that hasn't visibly broken.

Takeaways
Every Hook that uses `setTimeout` or `setInterval` must clear the timer in a `useEffect` cleanup function, or callbacks will fire on unmounted components.
Storing the latest callback in a `ref` and updating it on every render prevents stale closures from freezing state at its initial value.
A debounce or throttle Hook without a cancel method is incomplete; queued calls need a way to be interrupted when the user clears input or navigates away.
Throttle Hooks add a leading-vs-trailing decision that changes behavior entirely for scroll listeners versus button debouncing.
Utility Hooks copied once and never revisited are the most dangerous code in a project because they accumulate hidden edge-case bugs that only surface at scale.
Conclusions

The three bugs share a common root: treating a Hook as a one-time factory rather than a lifecycle-aware primitive. The original implementation creates a function once and assumes it stays valid forever, but React's rendering model guarantees it won't.

Stale closures are the hardest of the three to catch because the bug is invisible until a specific state dependency changes during the exact window when a timer is pending—a combination that almost never appears in manual testing.

The cancel-method gap reveals a design blind spot: debounce is typically framed as a performance optimization, not an interaction primitive, so the need to abort a pending action is overlooked until a clear-button bug makes it obvious.

Concepts & terms
Stale closure
A closure that captures variable values from an earlier render. In React Hooks, if a function is created once and held across re-renders, any state or props it closes over will remain frozen at their initial values instead of reflecting the latest render.
Leading vs. trailing throttle
Leading throttle executes the callback immediately on the first trigger and then ignores subsequent calls within the delay window. Trailing throttle defers execution until the end of the window, firing once after the last trigger. The choice matters for scroll handlers (leading) versus button debouncing (trailing).
Source: juejin.cn ↗ Google Translate ↗ Backup ↗