Summary
useDebouncedCallback schedules work with setTimeout but never cancels the pending timer when
the consuming component unmounts. If a debounced call is in flight when the component is torn down,
the timer still fires afterwards and runs callback against a component that no longer exists.
In practice this causes:
- "setState on an unmounted component" style warnings / no-op state updates.
- Test crashes — once the test environment (e.g. happy-dom / jsdom) has been torn down, the
late callback runs in a context where window is gone, throwing window is not defined.
- A small but real resource leak: any component that unmounts within
delay of its last
debounced call leaves a live timer holding a closure over stale props/state.
Environment
@coreui/react-pro (current — reproduced against useDebouncedCallback)
- React 17+ (any supported version)
- Any environment that can unmount a component while a debounce is pending (all of them);
most visible in test runners that tear down the DOM between/after tests.
Root cause
The hook stores the timer id in a ref and clears it on the next invocation, but there is no
unmount cleanup, so a timer scheduled by the last invocation outlives the component:
const useDebouncedCallback = (callback, delay) => {
const timeout = useRef<ReturnType<typeof setTimeout>>()
// No effect cleanup → the timeout below is never cleared on unmount. ← bug
return useCallback(
(...args) => {
if (timeout.current) {
clearTimeout(timeout.current)
}
timeout.current = setTimeout(() => {
callback(...args) // runs even if the component has since unmounted
}, delay)
},
[callback, delay],
)
}
clearTimeout only runs when the debounced function is called again. If the component unmounts
before the pending timer fires (and without another call), nothing cancels it.
Reduced test case
import { render, fireEvent } from '@testing-library/react'
import { useState } from 'react'
import { useDebouncedCallback } from '@coreui/react-pro'
function Widget() {
const [, setValue] = useState('')
const onChange = useDebouncedCallback((v: string) => setValue(v), 300)
return <input onChange={(e) => onChange(e.target.value)} />
}
const { getByRole, unmount } = render(<Widget />)
fireEvent.change(getByRole('textbox'), { target: { value: 'x' } }) // schedules a 300ms timer
unmount() // component gone, but the timer is still pending…
// …300ms later the timer fires and calls setValue on the unmounted component.
// In a torn-down test env this surfaces as "window is not defined" / setState warnings.
Fix
src/hooks/useDebouncedCallback.ts — add an unmount cleanup that clears the pending timer.
(Diff shown in TS; the compiled dist/{cjs,esm}/hooks/useDebouncedCallback.js equivalents were
used to confirm the change.)
-import { useCallback, useRef } from 'react'
+import { useCallback, useEffect, useRef } from 'react'
const useDebouncedCallback = (callback, delay) => {
const timeout = useRef<ReturnType<typeof setTimeout>>()
+ // Cancel any pending invocation when the consumer unmounts. Without this the
+ // setTimeout outlives the component and can call back after teardown (crashes
+ // tests with "window is not defined" once the DOM env is gone).
+ useEffect(() => () => clearTimeout(timeout.current), [])
+
return useCallback(
(...args) => {
if (timeout.current) {
clearTimeout(timeout.current)
}
timeout.current = setTimeout(() => {
callback(...args)
}, delay)
},
[callback, delay],
)
}
The empty dependency array means the cleanup runs once, on unmount, clearing whatever timer id is
current at that moment. No behavioural change for mounted components.
Test case
Add to src/hooks/__tests__/useDebouncedCallback.spec.tsx (new file). It asserts the callback does
not fire after unmount — fails before the fix, passes after.
import { renderHook, act } from '@testing-library/react'
import { useDebouncedCallback } from '../useDebouncedCallback'
beforeEach(() => jest.useFakeTimers())
afterEach(() => jest.useRealTimers())
describe('useDebouncedCallback', () => {
it('does not invoke the callback after the component unmounts', () => {
const callback = jest.fn()
const { result, unmount } = renderHook(() => useDebouncedCallback(callback, 300))
act(() => result.current('x')) // schedule
unmount() // unmount before the timer fires
act(() => jest.advanceTimersByTime(500)) // flush all timers
expect(callback).not.toHaveBeenCalled()
})
it('still debounces normally while mounted (regression guard)', () => {
const callback = jest.fn()
const { result } = renderHook(() => useDebouncedCallback(callback, 300))
act(() => {
result.current('a')
result.current('b') // supersedes 'a'
})
act(() => jest.advanceTimersByTime(300))
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith('b')
})
})
Summary
useDebouncedCallbackschedules work withsetTimeoutbut never cancels the pending timer whenthe consuming component unmounts. If a debounced call is in flight when the component is torn down,
the timer still fires afterwards and runs
callbackagainst a component that no longer exists.In practice this causes:
late callback runs in a context where
windowis gone, throwingwindow is not defined.delayof its lastdebounced call leaves a live timer holding a closure over stale props/state.
Environment
@coreui/react-pro(current — reproduced againstuseDebouncedCallback)most visible in test runners that tear down the DOM between/after tests.
Root cause
The hook stores the timer id in a ref and clears it on the next invocation, but there is no
unmount cleanup, so a timer scheduled by the last invocation outlives the component: