What's happening
You open the browser console and see this warning everywhere:
Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
Picture the typical culprit: a component kicks off a fetch() call โ maybe it takes 2โ3 seconds โ and the user clicks the back button halfway through. The fetch resolves anyway. The callback calls setUser(data) on a component that's already gone. React intercepts it and skips the update. No crash. But the request completed, the callback ran, and memory was held for nothing. Do this across enough components and you've got a real leak.
Reproduce the problem
Here's the minimal trigger:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // โ fires after unmount
}, [userId]);
return <div>{user?.name}</div>;
}
Unmount UserProfile before the fetch resolves โ a back button, a route change, a conditional render โ and setUser(data) lands on a dead component.
Fix 1: Cancel the fetch with AbortController (recommended)
AbortController kills the in-flight network request itself โ not just the callback. The browser stops the request mid-flight and throws an AbortError, which you catch and ignore:
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') return; // ignore cancellation
console.error(err);
});
return () => controller.abort(); // cleanup on unmount
}, [userId]);
return <div>{user?.name}</div>;
}
The cleanup function runs when the component unmounts, or when userId changes before the previous request finishes. controller.abort() cuts the fetch. The AbortError is swallowed silently. No state update, no warning, no wasted bandwidth.
Fix 2: isMounted flag (for non-fetch async work)
Some async work can't be cancelled โ a third-party SDK callback, a Promise.all() wrapping multiple operations, a debounced function. For those cases, gate the state update behind a boolean flag:
function DataWidget() {
const [data, setData] = useState(null);
useEffect(() => {
let isMounted = true;
someAsyncOperation().then(result => {
if (isMounted) setData(result); // only update if still mounted
});
return () => {
isMounted = false; // cleanup: disable stale callbacks
};
}, []);
return <div>{data}</div>;
}
The async operation still runs to completion โ it just won't touch state. Think of it as a safety guard, not a true fix. The work still happens; you're just ignoring the result.
Fix 3: Timers and intervals
Timers are probably the second most common offender after fetches. A setInterval left running after unmount will fire indefinitely. Always store the ID and clear it:
function LiveClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(interval); // cleanup
}, []);
return <span>{time.toLocaleTimeString()}</span>;
}
Fix 4: Event listeners and subscriptions
Register in the effect, unregister in the cleanup โ no exceptions:
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
RxJS subscriptions follow the same rule:
useEffect(() => {
const subscription = dataStream$.subscribe(val => setData(val));
return () => subscription.unsubscribe();
}, []);
Fix 5: React Query / SWR (long-term solution)
Writing AbortController boilerplate on every data-fetching component gets old fast. React Query handles cancellation, caching, deduplication, and cleanup automatically โ the whole problem disappears:
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
return <div>{user?.name}</div>;
}
No manual cleanup. No flags. If your codebase has more than a handful of fetch-in-useEffect patterns, migrating to React Query pays off quickly.
Verify the fix worked
- Open DevTools โ Console.
- Navigate to the component that showed the warning.
- Immediately navigate away before any async operations complete.
- Check the console โ the warning should be gone.
Want a more explicit signal? Drop a log in the cleanup during development:
return () => {
console.log('cleanup ran โ aborting fetch');
controller.abort();
};
If "cleanup ran" appears before the fetch response, the cleanup is wired correctly.
Note on React 18
React 18 removed this warning from production builds. Calling setState on an unmounted component is now silently ignored โ React won't tell you about it. Don't mistake the missing warning for a missing bug. The async operations still run, memory is still held, and stale callbacks can still produce unexpected behavior. Clean up your effects regardless of which React version you're on.
Key takeaways
- Write the cleanup function before writing the effect body. It forces you to think about what needs to be cancelled before you write the code that starts it.
AbortControllercancels the network request itself โ the browser stops the transfer. AnisMountedflag only ignores the result; the request completes anyway.- Timers fire forever if you don't clear them. A single forgotten
setIntervalin a modal can drain measurable CPU over a long session. - If you're writing cleanup boilerplate on every component, that's a sign to reach for React Query or SWR and let the library own the data lifecycle.

