Quick Answer
When you call setInterval or addEventListener inside a useEffect without returning a cleanup function, React continues running those subscriptions after the component unmounts. This causes memory leaks, stale state updates, and "Can't perform a React state update on an unmounted component" errors. The fix is always to return a cleanup function that calls clearInterval or removeEventListener.
Why useEffect Cleanup Matters
React's useEffect hook runs side effects after the component renders. The critical part that AI tools consistently skip is the return value: a function that React calls when the component unmounts or before the effect runs again. Without it, side effects that register listeners or start timers keep running indefinitely in the background.
According to the Chrome DevTools team, memory leaks are a contributing factor in 72% of mobile web application performance degradations. React applications are particularly vulnerable because components mount and unmount frequently - route changes, conditional rendering, and tab switching all trigger unmounts. Each unmounted component with a forgotten cleanup function leaves behind a ghost: a timer firing or a listener responding to events, often trying to call setState on a component that no longer exists in the DOM.
The practical symptoms range from subtle - your app gradually consuming more memory during a long session - to hard crashes where your event handler fires after navigation and tries to update state in a component that has already been garbage-collected by React's internal reconciler.
What AI Code Generators Produce
When you ask Bolt, Lovable, or Cursor to build a live data feed, countdown timer, or window resize handler, the generated code almost always registers the side effect correctly but omits cleanup entirely:
// ❌ BAD - No cleanup: interval keeps firing after unmount
import { useState, useEffect } from 'react';
function LivePriceWidget({ symbol }) {
const [price, setPrice] = useState(null);
useEffect(() => {
const interval = setInterval(async () => {
const data = await fetchPrice(symbol);
setPrice(data.price); // setState on unmounted component!
}, 1000);
// Missing: return () => clearInterval(interval);
}, [symbol]);
return {price ?? 'Loading...'};
}
This component fetches a price every second. When the user navigates away, the component unmounts - but the interval keeps running. Every tick still tries to call setPrice, producing the infamous React warning and potentially causing subtle bugs in whatever component React renders next.
The same pattern occurs with addEventListener:
// ❌ BAD - Event listener accumulates on every re-render
useEffect(() => {
window.addEventListener('resize', handleResize);
// Missing: return () => window.removeEventListener('resize', handleResize);
}, []);
If this effect runs with a dependency array that changes, React re-runs the effect, adding a second listener - without ever removing the first. After ten re-renders, you have ten resize handlers firing simultaneously.
The Correct Cleanup Pattern
The fix is mechanical: every useEffect that registers a subscription, starts a timer, opens a connection, or attaches a listener must return a cleanup function:
// ✅ GOOD - Cleanup function prevents memory leak
import { useState, useEffect } from 'react';
function LivePriceWidget({ symbol }) {
const [price, setPrice] = useState(null);
useEffect(() => {
let cancelled = false;
const interval = setInterval(async () => {
const data = await fetchPrice(symbol);
if (!cancelled) {
setPrice(data.price);
}
}, 1000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [symbol]);
return {price ?? 'Loading...'};
}
// ✅ GOOD - addEventListener with matching removeEventListener
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
The cancelled flag in the first example is an additional safeguard: even if the async callback inside the interval has already been queued when cleanup runs, it will check the flag before calling setPrice. This handles the race condition between the interval firing and the cleanup function running.
Common useEffect Patterns That Need Cleanup
| Side Effect | Start Call | Required Cleanup |
|---|---|---|
| Interval timer | setInterval(fn, ms) |
clearInterval(id) |
| Timeout | setTimeout(fn, ms) |
clearTimeout(id) |
| DOM event listener | addEventListener(event, fn) |
removeEventListener(event, fn) |
| WebSocket connection | new WebSocket(url) |
ws.close() |
| Supabase realtime | supabase.channel().subscribe() |
supabase.removeChannel(channel) |
| AbortController fetch | fetch(url, { signal }) |
controller.abort() |
| IntersectionObserver | observer.observe(el) |
observer.disconnect() |
Note that not every useEffect needs cleanup. If you're just setting document title, logging analytics, or triggering a one-time API call that doesn't hold a reference, cleanup may not be required. The rule is: if you registered something, you must unregister it.
React Strict Mode Exposes Missing Cleanup
React 18's Strict Mode deliberately mounts and unmounts every component twice in development. This was specifically designed to surface missing cleanup functions - if your component works incorrectly after a double-mount cycle, you have a leak. Many AI-generated projects disable Strict Mode to "fix" these errors rather than addressing the underlying issue.
According to the React team's documentation, Strict Mode double-invoking effects is the primary mechanism for detecting missing cleanup in the React 18 development workflow. If you see your effects running twice and causing errors, the correct response is to add cleanup - not to remove <React.StrictMode> from your app root.
Next.js projects generated by Bolt, Lovable, or v0 often include <React.StrictMode> by default in the Next.js config, which is why memory leak warnings appear immediately during development of AI-generated apps.
How to Find and Fix Missing Cleanup in Your Codebase
- Search for unguarded useEffect calls: Look for
useEffectblocks containingsetInterval,setTimeout,addEventListener, orsubscribecalls. If the block does not end with areturn () =>, it likely needs cleanup. - Enable React Strict Mode: In development, React will double-invoke effects. Any leak will manifest immediately as a doubled timer or duplicated event handler.
- Check the browser Memory panel: In Chrome DevTools, take heap snapshots before and after navigating through your app. Growing heap size between snapshots indicates retained closures from leaked effects.
- Run automated scanning: Tools like VibeDoctor (vibedoctor.io) automatically scan your codebase for useEffect blocks missing cleanup functions (FE-006) and flag specific file paths and line numbers. Free to sign up.
- Use the eslint-plugin-react-hooks rule: The
react-hooks/exhaustive-depsESLint rule catches some cleanup issues, though it doesn't specifically enforce cleanup return values.
FAQ
Does every useEffect need a cleanup function?
No. Effects that don't register subscriptions, start timers, or attach listeners don't need cleanup. For example, useEffect(() => { document.title = 'Dashboard'; }, []) has nothing to clean up. The rule applies only when you start something that continues running independently of React's rendering.
What happens if I forget cleanup in a Next.js app?
In a Next.js app with client-side routing, components unmount on every navigation. A missing cleanup means your intervals and listeners accumulate as users navigate. After visiting ten pages, you may have ten background intervals running. This degrades performance and can cause stale data bugs where an old component's interval overwrites state in a newer one.
Can I use AbortController to cancel fetch calls in useEffect?
Yes, and you should. Create an AbortController inside the effect, pass its signal to fetch, and call controller.abort() in the cleanup function. This cancels in-flight requests when the component unmounts, preventing stale responses from updating state after navigation.
Does React Query or SWR handle cleanup automatically?
Yes. Libraries like TanStack Query (React Query) and SWR manage their own subscriptions and cleanup internally. If you're using these libraries for data fetching, you don't need to manually manage cleanup for those fetches. However, any direct setInterval, addEventListener, or WebSocket connections you add alongside them still require manual cleanup.
Is this different in React Server Components?
React Server Components don't run in the browser, so useEffect doesn't exist in them. The memory leak problem applies only to Client Components (files with "use client" at the top in Next.js App Router). If you're building a purely server-rendered page, this issue doesn't apply - but the moment you add interactivity with a Client Component, it does.