How to Fix 'Can't Perform a React State Update on an Unmounted Component' Memory Leak
Bug Severity: HIGH | UX/User Impact: MODERATE (silent memory leak, potential stale state renders, degraded performance over time) | Time to Fix: 10–20 mins
TL;DR
- What broke: An async operation (API fetch, timer, or subscription) resolved after the component was removed from the DOM and attempted to call
setState, leaking memory and potentially rendering stale data. - How to fix it: Return a cleanup function from
useEffectthat cancels the async operation viaAbortController,clearTimeout, or an unsubscribe call before the component unmounts. - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your component and get the corrected cleanup pattern instantly.
The Incident (What Does the Error Mean?)
Raw console output:
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.
React detected that something called setState (or a useState setter) on a component instance that no longer exists in the tree. React 18 silenced this warning in some cases, but the underlying leak is still real — the async task, event listener, or subscription is still running in the background, holding a reference to the component's closure and preventing garbage collection.
In class components this manifested on this.setState. In hooks it's a useState setter captured inside a useEffect or an async callback with no cleanup.
The Attack Vector / Blast Radius
This isn't a crash — it's a slow bleed. Here's the cascading failure path:
- User navigates away from a page mid-fetch. The component unmounts.
- The in-flight
fetchresolves 800ms later. The.then()callback fires, callssetData(response)on a dead component. - Memory is not released. The closure holds the setter, which holds the component fiber, which holds the entire subtree's state and props.
- In a long-running SPA (dashboards, admin panels), users navigating repeatedly accumulate dozens of these zombie closures. Heap usage climbs. On low-end devices, the tab crashes.
- Stale state side effects: If the component remounts quickly (React Router navigation back), the resolved promise from the previous mount can overwrite fresh state, causing ghost data renders that are nearly impossible to reproduce in QA.
Blast radius is proportional to navigation frequency and payload size. A component fetching a 2MB dataset that leaks on every route transition will OOM a mobile browser within minutes of normal use.
How to Fix It (The Solution)
Root Cause Pattern
The canonical offender:
- useEffect(() => {
- fetch('/api/data')
- .then(res => res.json())
- .then(data => setData(data)); // fires even after unmount
- }, []);
Basic Fix — AbortController (Fetch Cancellation)
useEffect(() => {
+ const controller = new AbortController();
+
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
- .then(data => setData(data));
+ .then(data => setData(data))
+ .catch(err => {
+ if (err.name === 'AbortError') return; // expected, not a real error
+ console.error(err);
+ });
+
+ return () => controller.abort(); // cleanup fires on unmount
}, []);
AbortController signals the browser's networking layer to drop the request. The .catch guard on AbortError is mandatory — without it you'll swap one console warning for an unhandled rejection.
Basic Fix — Timer Leak (setTimeout / setInterval)
useEffect(() => {
- setTimeout(() => setVisible(true), 3000);
+ const timer = setTimeout(() => setVisible(true), 3000);
+ return () => clearTimeout(timer);
}, []);
Enterprise Best Practice — Centralized Async Management with Custom Hook
For production codebases with dozens of data-fetching components, a useSafeAsync hook enforces cleanup as the default, not an afterthought:
- // Ad-hoc fetch in every component — no consistent cleanup
- useEffect(() => {
- fetchUser(userId).then(setUser);
- }, [userId]);
+ // hooks/useSafeAsync.ts
+ import { useEffect, useRef, useCallback } from 'react';
+
+ export function useSafeAsync() {
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => { mountedRef.current = false; };
+ }, []);
+
+ const safeSetState = useCallback(
+ <T>(setter: React.Dispatch<React.SetStateAction<T>>, value: T) => {
+ if (mountedRef.current) setter(value);
+ },
+ []
+ );
+
+ return { safeSetState, isMounted: mountedRef };
+ }
+
+ // In your component:
+ const { safeSetState } = useSafeAsync();
+ useEffect(() => {
+ const controller = new AbortController();
+ fetchUser(userId, { signal: controller.signal })
+ .then(user => safeSetState(setUser, user));
+ return () => controller.abort();
+ }, [userId]);
Note: The isMounted ref pattern is a fallback for third-party async utilities that don't support AbortSignal. Always prefer AbortController for fetch — it actually cancels the network request rather than just suppressing the state update.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your component trees, API endpoint paths, and internal data structures. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing component into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
Catch this class of bug before it ships:
1. ESLint — eslint-plugin-react-hooks
The exhaustive-deps rule won't catch missing cleanups directly, but pairing it with eslint-plugin-react's react/no-deprecated and a custom rule catches setter calls in async callbacks:
npm install --save-dev eslint-plugin-react-hooks
// .eslintrc
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
2. React Testing Library — Leak Detection in Unit Tests
// Wrap async tests to catch act() warnings which surface unmount leaks
it('cleans up fetch on unmount', async () => {
const { unmount } = render(<MyComponent />);
unmount(); // immediately unmount
// Assert no console.error was called with 'unmounted component'
expect(console.error).not.toHaveBeenCalled();
});
Add jest.spyOn(console, 'error') in your beforeEach and assert in afterEach. Any unmount leak will fail the test suite in CI.
3. Chrome DevTools Memory Profiling in Pre-Prod
- Record a Heap Snapshot before and after navigating to/from the leaking route.
- Filter by
Detachedin the snapshot diff. - Any Detached HTMLElement or Detached Fiber referencing your component name is a confirmed leak.
Make heap snapshot diffing a mandatory step in your release checklist for any PR touching data-fetching components.