| |

React 106 ⚛️ Memory Leak Prevention and Cleanup Strategies

⏱️ Estimated read: 11 min | |

A React memory leak is not a failure of React’s garbage collection. React unmounts the component, removes its DOM, and drops its internal references. The leak persists because something outside React still holds a reference to the component’s state, its DOM nodes, or its closures. The common sources are timers, event listeners on window or document, subscriptions, and asynchronous callbacks that resolve after unmount and call setState on a component that no longer exists. The fix is always the same pattern: acquire the resource in an effect, release it in the cleanup function that the same effect returns.

Key point: React’s useEffect cleanup function is the mechanism for preventing leaks. Every resource acquired inside an effect must be released in the cleanup. The cleanup runs before the next effect execution and on unmount. A resource acquired outside an effect—in a module scope, in a ref initializer, in a global singleton—has no cleanup and must be managed manually.


Why memory leaks persist despite React’s cleanup

The effect cleanup model. An effect that returns a function tells React to run that function before the next effect run and on unmount. The cleanup is where listeners are removed, timers are cleared, and subscriptions are cancelled. React calls it deterministically, so the resource lifecycle is tied to the component lifecycle. An effect without a cleanup is a resource without a release.

The asynchronous state update problem. A fetch call or a setTimeout callback can complete after the component has unmounted. Calling setState on an unmounted component was a warning in React 17 and is a no-op in React 18, but the closure still holds a reference to the component’s state and the callback still runs. The leak is not the state update; it is the reference the pending callback holds until it completes.

The external reference problem. A module-level variable, a global event emitter, a WebSocket connection, or a third-party library instance can hold a reference to a React component’s callback or state. If the reference is not released on unmount, the component’s entire state tree stays in memory. The profiler’s retainers panel is the tool that shows which external reference is holding it.


a. The Effect Cleanup Pattern

Every resource acquired in an effect must be released in the cleanup. The pattern is the same for timers, listeners, and subscriptions.

import { useEffect, useState } from 'react'

function Timer() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1)
    }, 1000)

    // Cleanup: clear the timer on unmount
    return () => clearInterval(id)
  }, [])

  return <div>{count}</div>
}

The setInterval returns an ID. The cleanup function captures that ID and calls clearInterval. When the component unmounts, React calls the cleanup, the interval stops, and the callback is released.

The event listener follows the same shape:

function WindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 })

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight })
    }

    window.addEventListener('resize', handleResize)

    // Cleanup: remove the listener on unmount
    return () => window.removeEventListener('resize', handleResize)
  }, [])

  return <div>{size.width} × {size.height}</div>
}

The listener function is the same reference in both addEventListener and removeEventListener. If the function is recreated on each render, the cleanup removes the old reference and the new effect adds the new one. The cleanup captures the correct function because it closes over the same variable.


b. Asynchronous Callbacks and Abort Controllers

A fetch request that resolves after unmount is a leak. The promise holds the callback, the callback holds the component’s state, and the state stays in memory until the request completes. The AbortController is the mechanism for cancelling the request on unmount.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)

  useEffect(() => {
    const controller = new AbortController()

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err)
      })

    // Cleanup: abort the request on unmount or userId change
    return () => controller.abort()
  }, [userId])

  if (!user) return <div>Loading...</div>
  return <div>{user.name}</div>
}

The controller.abort() call cancels the fetch, rejects the promise with an AbortError, and releases the callback. The .catch handler checks for AbortError to avoid logging the expected cancellation as an error.

For callbacks that cannot be cancelled—a promise that is already resolved, a setTimeout callback that is already queued—the pattern is an isMounted ref that the callback checks before calling setState.

function DelayedMessage() {
  const [message, setMessage] = useState('')
  const isMounted = useRef(true)

  useEffect(() => {
    return () => { isMounted.current = false }
  }, [])

  useEffect(() => {
    setTimeout(() => {
      if (isMounted.current) setMessage('Hello')
    }, 2000)
  }, [])

  return <div>{message}</div>
}

The isMounted ref is set to false in the cleanup. The timer callback checks it before calling setState. The pattern is a fallback for cases where cancellation is not possible; AbortController is the preferred mechanism when the API supports it.


c. External References and Subscription Cleanup

A subscription to an external store—a WebSocket, a global event emitter, a third-party library—must be unsubscribed on unmount. The pattern is the same: the subscribe call returns an unsubscribe function, and the effect returns that function as the cleanup.

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([])

  useEffect(() => {
    const socket = new WebSocket(`wss://chat.example.com/${roomId}`)

    socket.onmessage = (event) => {
      setMessages(prev => [...prev, event.data])
    }

    // Cleanup: close the socket on unmount or roomId change
    return () => socket.close()
  }, [roomId])

  return <ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>
}

The socket.close() call closes the connection, which releases the onmessage handler and the component’s state. Without it, the socket stays open after unmount, and each message pushes to a state array that no one is rendering.

A third-party library instance with its own DOM and listeners requires the library’s own cleanup method. A Chart.js chart calls chart.destroy(). A Mapbox map calls map.remove(). A CodeMirror editor calls view.destroy(). The effect returns the cleanup that calls the appropriate method.

function Chart({ data }) {
  const canvasRef = useRef(null)
  const chartRef = useRef(null)

  useEffect(() => {
    chartRef.current = new Chart(canvasRef.current, { data })

    return () => {
      chartRef.current?.destroy()
      chartRef.current = null
    }
  }, [data])

  return <canvas ref={canvasRef} />
}

Complete Example Session

// ============================================
// PART 1: TIMER CLEANUP
// ============================================
useEffect(() => {
  const id = setInterval(() => setCount(c => c + 1), 1000)
  return () => clearInterval(id)
}, [])


// ============================================
// PART 2: EVENT LISTENER CLEANUP
// ============================================
useEffect(() => {
  const handler = () => setSize(window.innerWidth)
  window.addEventListener('resize', handler)
  return () => window.removeEventListener('resize', handler)
}, [])


// ============================================
// PART 3: FETCH WITH ABORT CONTROLLER
// ============================================
useEffect(() => {
  const controller = new AbortController()
  fetch(`/api/users/${id}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') console.error(err)
    })
  return () => controller.abort()
}, [id])


// ============================================
// PART 4: WEBSOCKET CLEANUP
// ============================================
useEffect(() => {
  const socket = new WebSocket(url)
  socket.onmessage = (e) => setMessages(prev => [...prev, e.data])
  return () => socket.close()
}, [url])


// ============================================
// PART 5: THIRD-PARTY LIBRARY CLEANUP
// ============================================
useEffect(() => {
  const instance = new Library(canvasRef.current)
  return () => instance.destroy()
}, [])


// ============================================
// PART 6: IS-MOUNTED REF FOR NON-CANCELLABLE CALLBACKS
// ============================================
const isMounted = useRef(true)
useEffect(() => {
  return () => { isMounted.current = false }
}, [])

The six parts trace the four most common cleanup patterns: timers, listeners, cancellable requests, and subscriptions. Part 6 is the fallback for callbacks that cannot be cancelled.


Quick Reference

Resource → Cleanup

ResourceAcquired WithReleased With
TimersetInterval / setTimeoutclearInterval / clearTimeout
Event listeneraddEventListenerremoveEventListener
FetchfetchAbortController.abort()
WebSocketnew WebSocket()socket.close()
Subscriptionsubscribe()unsubscribe()
Third-partynew Library()instance.destroy()
Observernew IntersectionObserver()observer.disconnect()

Cleanup Patterns

PatternWhen
Return cleanup from effectAll resources acquired in the effect
AbortControllerCancellable async requests
isMounted refNon-cancellable async callbacks
Library destroy()Third-party instances

Effect Lifecycle

EventCleanup Runs
Dependency changeBefore the new effect
Component unmountYes
StrictMode double-invokeAfter first mount, before second
Never (no cleanup)Never

Best Practices

✅ Do This:

// Return a cleanup from every effect that acquires a resource
useEffect(() => {
  const id = setInterval(fn, 1000)
  return () => clearInterval(id)
}, [])                                                                  // ✅
// Use AbortController for fetch
useEffect(() => {
  const controller = new AbortController()
  fetch(url, { signal: controller.signal })
  return () => controller.abort()
}, [url])                                                               // ✅
// Use the same function reference for add and remove
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)              // ✅

❌ Don’t Do This:

// Effect without cleanup for a timer
useEffect(() => { setInterval(fn, 1000) }, [])  // leaked                  // ❌
// Fetch without abort
useEffect(() => { fetch(url).then(setData) }, [])  // may resolve post-unmount // ❌
// Different function reference for remove
window.addEventListener('resize', () => {})
return () => window.removeEventListener('resize', () => {})  // wrong ref  // ❌

Common Pitfalls

PitfallWhy It HappensFix
Memory grows on navigationListeners or timers not cleanedReturn cleanup from effect
setState after unmountAsync callback resolves lateAbortController or isMounted ref
Listener not removedNew function reference on each renderStable handler or capture the correct ref
Third-party leakNo destroy() callCall library cleanup in effect return
WebSocket stays openNo close() on unmountReturn socket.close from effect

Real-World Examples

1. Timer Cleanup

useEffect(() => { const id = setInterval(fn, 1000); return () => clearInterval(id) }, [])

2. Resize Listener

useEffect(() => {
  window.addEventListener('resize', handler)
  return () => window.removeEventListener('resize', handler)
}, [])

3. Fetch with Abort

useEffect(() => {
  const c = new AbortController()
  fetch(url, { signal: c.signal }).then(setData)
  return () => c.abort()
}, [url])

4. WebSocket

useEffect(() => {
  const ws = new WebSocket(url)
  return () => ws.close()
}, [url])

5. Third-Party Destroy

useEffect(() => {
  const chart = new Chart(canvas)
  return () => chart.destroy()
}, [])

6. Intersection Observer

useEffect(() => {
  const obs = new IntersectionObserver(cb)
  obs.observe(ref.current)
  return () => obs.disconnect()
}, [])

7. isMounted Ref

const isMounted = useRef(true)
useEffect(() => () => { isMounted.current = false }, [])

8. Subscription

useEffect(() => {
  const unsub = store.subscribe(handler)
  return unsub
}, [])

9. Event Emitter

useEffect(() => {
  emitter.on('event', handler)
  return () => emitter.off('event', handler)
}, [])

10. StrictMode Verification

// In development, StrictMode runs effect → cleanup → effect
// The cleanup must be correct

Visual

The Effect Cleanup Lifecycle

┌──────────────────────────────────────────────────────────────┐
│  MOUNT                                                       │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ useEffect(() => {                                       ││
│  │   acquire resource                                      ││
│  │   return cleanup                                        ││
│  │ })                                                      ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │                                │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Effect runs: acquire resource                           ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │                                │
│                             │ dependency change or unmount  │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Cleanup runs: release resource                          ││
│  └──────────────────────────┬──────────────────────────────┘│
│                             │                                │
│                             ▼                                │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ If dependency changed: effect runs again                ││
│  │ If unmounted: done                                      ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  The cleanup is deterministic.                               │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Fetch Leak

┌──────────────────────────────────────────────────────────────┐
│  WITHOUT ABORT                                               │
│                                                              │
│  Component mounts → fetch starts → component unmounts       │
│  → fetch resolves → setState on unmounted component         │
│  → closure holds state → memory not released                │
│                                                              │
│  WITH ABORT                                                  │
│                                                              │
│  Component mounts → fetch starts → component unmounts       │
│  → controller.abort() → fetch rejects with AbortError       │
│  → closure released → memory reclaimed                      │
│                                                              │
│  The AbortController is the cancellation mechanism.          │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Listener Reference

┌──────────────────────────────────────────────────────────────┐
│  ❌ WRONG                                                    │
│  window.addEventListener('resize', () => {})                 │
│  return () => window.removeEventListener('resize', () => {}) │
│                                                              │
│  Two different function references.                          │
│  removeEventListener does not match the registered function. │
│                                                              │
│  ✅ CORRECT                                                  │
│  const handler = () => {}                                    │
│  window.addEventListener('resize', handler)                  │
│  return () => window.removeEventListener('resize', handler)  │
│                                                              │
│  Same reference. The listener is removed.                    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Cleanup mechanismReturn function from useEffect
Timer cleanupclearInterval / clearTimeout
Listener cleanupremoveEventListener
Fetch cleanupAbortController.abort()
WebSocket cleanupsocket.close()
Third-party cleanupLibrary’s destroy() method
Non-cancellable callbacksisMounted ref
StrictModeRuns effect → cleanup → effect in dev

Key takeaways:

  • Every resource acquired in an effect must be released in the cleanup. React calls the cleanup deterministically: before the next effect execution and on unmount. An effect without a cleanup is a resource without a release.
  • AbortController is the cancellation mechanism for fetch. The controller.abort() call cancels the request, rejects the promise with AbortError, and releases the callback. The .catch handler checks for the error name to avoid logging the expected cancellation.
  • The listener function must be the same reference in add and remove. A new arrow function on every render produces a different reference, and removeEventListener does not match the registered function. The handler should be defined outside the effect or captured in a variable.
  • Third-party libraries have their own cleanup methods. A Chart.js chart calls chart.destroy(). A Mapbox map calls map.remove(). A CodeMirror editor calls view.destroy(). The effect returns the cleanup that calls the appropriate method.
  • StrictMode in development runs the effect, the cleanup, and the effect again. This is a diagnostic that verifies the cleanup is correct. If the cleanup is missing or wrong, the double-invoke makes the leak visible.

Remember: React’s garbage collector is not the problem. React unmounts the component and drops its references. The leak persists because something outside React still holds a reference: a timer that was never cleared, a listener that was never removed, a fetch that is still pending, a WebSocket that is still open. The useEffect cleanup function is the mechanism for releasing those references. The pattern is consistent: acquire the resource in the effect, return the release function as the cleanup. When the pattern is followed, the component’s memory is released when the component unmounts. When it is not, the profiler’s retainers panel will show exactly which external reference is holding the component in memory.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!