How to fix memory leaks in React
Memory leaks in React occur when components are unmounted but still hold references to timers, subscriptions, or callbacks.
As the creator of CoreUI with over 10 years of React experience since 2014, I’ve debugged memory leaks that caused applications to slow down and eventually crash after extended use.
The primary fix is returning cleanup functions from useEffect to cancel ongoing operations before the component unmounts.
This prevents the classic “Can’t perform a React state update on an unmounted component” warning.
Return a cleanup function from useEffect to cancel timers.
// ❌ Memory leak - timer keeps running after unmount
useEffect(() => {
const id = setInterval(() => {
setData(fetchData())
}, 1000)
}, [])
// ✅ Fixed - timer cancelled on unmount
useEffect(() => {
const id = setInterval(() => {
setData(fetchData())
}, 1000)
return () => clearInterval(id)
}, [])
The function returned from useEffect runs when the component unmounts or before the effect re-runs. Calling clearInterval stops the timer. Without cleanup, the interval fires forever and may try to update state on an unmounted component.
Cancelling Fetch Requests
Abort in-flight requests when the component unmounts.
// ❌ Sets state on unmounted component
useEffect(() => {
fetch('/api/data')
.then(r => r.json())
.then(data => setData(data))
}, [])
// ✅ Aborts request on unmount
useEffect(() => {
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
.then(r => r.json())
.then(data => setData(data))
.catch(err => {
if (err.name !== 'AbortError') throw err
})
return () => controller.abort()
}, [])
AbortController cancels the fetch request. The cleanup calls controller.abort(). The .catch ignores AbortError since it’s expected. Without this, navigating away causes the response handler to call setData on the already-unmounted component.
Unsubscribing from Event Listeners
Remove event listeners in the cleanup function.
// ❌ Listener accumulates on each render
useEffect(() => {
window.addEventListener('resize', handleResize)
})
// ✅ Listener removed on unmount
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [handleResize])
Without a dependency array the effect runs on every render, adding a new listener each time. Adding a cleanup function and a dependency array ensures the listener is added once and removed when the component unmounts.
Cancelling Subscriptions
Unsubscribe from observables and event emitters.
import { useEffect } from 'react'
useEffect(() => {
const subscription = dataStream.subscribe(value => {
setValue(value)
})
return () => subscription.unsubscribe()
}, [])
useEffect(() => {
const off = eventBus.on('update', handleUpdate)
return () => off()
}, [])
Any subscription that delivers values asynchronously needs cleanup. Call .unsubscribe(), .off(), or whatever the library provides for teardown. Return the cleanup as the effect’s return value.
Using an isMounted Flag
Guard setState calls in older patterns.
useEffect(() => {
let isMounted = true
async function loadData() {
const data = await fetchData()
if (isMounted) {
setData(data)
}
}
loadData()
return () => {
isMounted = false
}
}, [])
The isMounted flag gates all state updates. The cleanup sets it to false. This is a fallback for cases where you can’t use AbortController. Prefer AbortController for fetch requests.
Best Practice Note
This is the same leak prevention pattern we use throughout CoreUI React components. Every useEffect that starts something asynchronous - timer, fetch, subscription, event listener - must return a cleanup function. React’s StrictMode intentionally mounts and unmounts components twice in development to surface missing cleanups. If you see warnings in development, fix them before they become production bugs. For complex cleanup needs, extract the logic into a custom hook so the cleanup is colocated with the setup code.



