ReactCleanup Functions in useEffect

Cleanup Functions in useEffect

When useEffect starts something — an interval, an event listener, a network request, a WebSocket connection — it takes responsibility for stopping that thing when it is no longer needed. That is the job of the cleanup function: the optional function you return from your effect.

Why Cleanup Is Necessary

Without cleanup, side effects outlive the components that started them. This causes:

  • Memory leaks — subscriptions and listeners keep your component in memory even after it unmounts

  • Stale state updates — a fetch that completes after unmount will try to call setState on a dead component

  • Phantom timers — intervals that keep firing even though the component is gone, potentially causing bugs in other parts of the app

  • Double-execution bugs — in React 18 Strict Mode, effects run twice in development; without cleanup the second run stacks on top of the first

When the Cleanup Runs

React calls the cleanup function in two situations:

  • Before re-running the effect — whenever the dependencies change and the effect is about to run again, React first cleans up the previous run

  • When the component unmounts — the cleanup runs one final time

JSX
useEffect(() => {
  console.log('Effect ran with:', value)

  return () => {
    // Runs before the next time this effect runs,
    // AND when the component unmounts.
    console.log('Cleaning up previous effect with:', value)
  }
}, [value])
Example 1: Clearing Timers

setTimeout and setInterval both return a timer ID that you pass to clearTimeout/clearInterval to stop them. Without cleanup, the timer keeps firing even after the component is gone.

JSX
import { useState, useEffect } from 'react'

// A countdown that cleans up when the component unmounts or delay changes
function Countdown({ seconds }) {
  const [remaining, setRemaining] = useState(seconds)

  useEffect(() => {
    if (remaining <= 0) return

    // Start the interval
    const id = setInterval(() => {
      setRemaining(r => r - 1)
    }, 1000)

    // Cleanup: stop the interval
    return () => clearInterval(id)
  }, [remaining])

  if (remaining <= 0) return <p>Done!</p>
  return <p>{remaining} seconds remaining</p>
}

// A debounce example using setTimeout
function SearchInput({ onSearch }) {
  const [query, setQuery] = useState('')

  useEffect(() => {
    if (!query) return

    // Wait 400ms after the user stops typing before searching
    const timer = setTimeout(() => {
      onSearch(query)
    }, 400)

    // Cleanup: if query changes before 400ms, cancel the previous timer
    return () => clearTimeout(timer)
  }, [query, onSearch])

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
      placeholder="Search..."
    />
  )
}

In the SearchInput example, every keystroke triggers a new render. React cancels the previous timer before starting a new one, so onSearch is only called once the user pauses for 400ms — a proper debounce.

Example 2: Removing Event Listeners

addEventListener registers a callback globally. If you never call removeEventListener, the callback holds a reference to your component closure forever — even after the component unmounts and the user has navigated away.

JSX
import { useState, useEffect } from 'react'

function KeyLogger() {
  const [lastKey, setLastKey] = useState(null)

  useEffect(() => {
    function handleKeyDown(event) {
      setLastKey(event.key)
    }

    window.addEventListener('keydown', handleKeyDown)

    // Cleanup: remove the listener when the component unmounts
    return () => {
      window.removeEventListener('keydown', handleKeyDown)
    }
  }, []) // [] = add listener once, remove on unmount

  return <p>Last key pressed: {lastKey ?? 'none'}</p>
}

// Multiple event listeners — clean them all up
function DragTracker() {
  const [position, setPosition] = useState({ x: 0, y: 0 })

  useEffect(() => {
    function handleMouseMove(e) {
      setPosition({ x: e.clientX, y: e.clientY })
    }
    function handleTouchMove(e) {
      setPosition({ x: e.touches[0].clientX, y: e.touches[0].clientY })
    }

    window.addEventListener('mousemove', handleMouseMove)
    window.addEventListener('touchmove', handleTouchMove)

    return () => {
      window.removeEventListener('mousemove', handleMouseMove)
      window.removeEventListener('touchmove', handleTouchMove)
    }
  }, [])

  return <p>Pointer: {position.x}, {position.y}</p>
}
Note
Always pass the **same function reference** to both `addEventListener` and `removeEventListener`. If you inline an arrow function in both calls, they will be different references and the listener will never be removed. Define the handler as a named variable first.
Example 3: Cancelling Fetch Requests with AbortController

When userId changes, the old fetch request is no longer needed. If it completes after the new request, it can overwrite the newer data — a race condition. AbortController solves this by cancelling the previous request in the cleanup function.

JSX
import { useState, useEffect } from 'react'

function UserCard({ userId }) {
  const [user, setUser] = useState(null)
  const [status, setStatus] = useState('idle') // 'idle' | 'loading' | 'error'

  useEffect(() => {
    if (!userId) return

    const controller = new AbortController()
    setStatus('loading')

    fetch(`/api/users/${userId}`, {
      signal: controller.signal,
    })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(data => {
        setUser(data)
        setStatus('idle')
      })
      .catch(err => {
        if (err.name === 'AbortError') {
          // Expected — we cancelled it ourselves. Ignore.
          return
        }
        setStatus('error')
      })

    return () => {
      // Cancel the in-flight request when:
      // - userId changes (new request is starting), or
      // - the component unmounts
      controller.abort()
    }
  }, [userId])

  if (status === 'loading') return <p>Loading user...</p>
  if (status === 'error') return <p>Failed to load user</p>
  if (!user) return null
  return <p>{user.name}</p>
}

Without the AbortController, if you clicked between users quickly, the slowest request would determine what you see last — not the most recent click. With cleanup, each new userId cancels the previous fetch before starting a new one.

StrictMode Reveals Missing Cleanups

In React 18 development mode, React Strict Mode intentionally mounts → unmounts → remounts every component. This means every effect runs twice in sequence:

  • Mount → effect runs

  • Unmount → cleanup runs

  • Remount → effect runs again

This is not a bug — it is React stress-testing your effects. If your app behaves incorrectly in Strict Mode dev, it means your cleanup is missing or incorrect. Fix the cleanup, not the Strict Mode behavior.

JSX
// ❌ Breaks in Strict Mode — adds two listeners, removes only one
useEffect(() => {
  window.addEventListener('resize', handleResize) // called twice
  // no cleanup!
}, [])

// ✅ Works correctly in Strict Mode — add one, remove one
useEffect(() => {
  window.addEventListener('resize', handleResize)
  return () => window.removeEventListener('resize', handleResize)
}, [])
Warning
The double-invocation only happens in **development** with Strict Mode. Production builds run effects exactly once. Never disable Strict Mode to "fix" a bug that only appears in dev — it means you have a real cleanup bug that will also manifest in production under certain conditions (like React's future features using concurrent rendering).
The Cleanup Pattern at a Glance

Every effect that "starts something" should "stop something" in its cleanup:

  • addEventListenerremoveEventListener

  • setInterval / setTimeoutclearInterval / clearTimeout

  • fetch(...)controller.abort()

  • socket.connect()socket.disconnect()

  • subscribe(handler)unsubscribe(handler) or subscription.cancel()

  • observer.observe(el)observer.disconnect()

Tip
A useful mental check: if you duplicated the effect code and ran it twice back-to-back without calling cleanup in between, would anything break? If yes, your cleanup is incomplete.