Skip to content

useDebouncedCallback schedules work with setTimeout but never cancels the pending timer #479

@aidanlister

Description

@aidanlister

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')
  })
})

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions