TL;DR
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 means a .then(), setTimeout, or subscription callback ran after the component that started it was removed from the tree, and tried to call setState anyway. The fix is to cancel the operation in your useEffect cleanup function — not to gate the setState call with an if check, which hides the leak instead of closing it.
- Symptom: Console warning naming the exact component, usually right after navigating away from a page mid-fetch
-
Root cause: An async operation (fetch, timer, subscription) outlives the component and calls
setStateon unmount -
Fix: Cancel the operation in the
useEffectcleanup —AbortControllerfor fetch,clearTimeoutfor timers,unsubscribe()for subscriptions -
Anti-pattern: An
isMountedboolean ref silences the warning but leaves the underlying request running to completion, wasting the network call
Why this is one of the highest-traffic React questions ever asked
The Stack Overflow question "Can't perform a React state update on an unmounted component" has over 774,000 views and 436 upvotes — it is one of the most-viewed React troubleshooting questions on the entire site. That volume exists because almost every data-fetching component has this bug latent inside it from the first draft: fetch on mount, setState when the response arrives, done. The bug only surfaces once a user navigates away before the response lands — fast networks and local development hide it for weeks.
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 the
componentWillUnmount method.
The message names componentWillUnmount because it predates hooks, but the equivalent for function components is the cleanup function returned from useEffect.
The bug, isolated
// ❌ classic version of the bug
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data)); // fires even if unmounted by now
}, [userId]);
return user ? <div>{user.name}</div> : <div>Loading…</div>;
}
If the user clicks away from this component before the fetch resolves, the .then() callback still runs — closures don't know the component is gone — and setState fires against a component React has already torn down.
The anti-pattern: the isMounted ref
This is the fix that shows up in most of the older Stack Overflow answers, and it silences the warning without actually solving the leak:
// ⚠️ suppresses the warning but the fetch still completes uselessly
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const isMounted = useRef(true);
useEffect(() => {
isMounted.current = true;
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (isMounted.current) setUser(data);
});
return () => {
isMounted.current = false;
};
}, [userId]);
}
The setState call is guarded, so React never complains — but the fetch request itself keeps running on the network and the server keeps doing the work of answering it. On a page with fast, repeated navigation (a search-as-you-type list, a tab switcher), this pattern accumulates in-flight requests that do nothing useful. It is a real fix for the symptom, not the cause.
The fix: cancel instead of check
AbortController cancels the request itself, which is what an unmounted component should actually do with work it no longer needs:
// ✅ cancels the request when the component unmounts
function UserProfile({ userId }: { userId: string }) {
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') throw err; // ignore the cancellation itself
});
return () => controller.abort();
}, [userId]);
return user ? <div>{user.name}</div> : <div>Loading…</div>;
}
When the component unmounts (or userId changes, re-running the effect), controller.abort() cancels the in-flight request. The browser stops downloading the response, the .then() chain never reaches setUser, and no warning fires — because the state update genuinely never happens, rather than being silently swallowed.
The same pattern for timers and subscriptions
setTimeout/setInterval and any subscription-based API (WebSockets, Supabase Realtime channels, EventSource) hit the identical bug and need the identical shape of fix — clear or unsubscribe in the cleanup, never guard the callback:
// Timer
useEffect(() => {
const id = setTimeout(() => setStatus('done'), 3000);
return () => clearTimeout(id);
}, []);
// Subscription (e.g. Supabase Realtime)
useEffect(() => {
const channel = supabase
.channel('room-1')
.on('broadcast', { event: 'message' }, (payload) => setMessages((m) => [...m, payload]))
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
Libraries that already solve this for you
If most of an app's data fetching goes through raw useEffect + fetch, migrating to a data-fetching library that owns the cancellation lifecycle removes this entire class of bug rather than requiring an AbortController at every call site. TanStack Query and SWR both cancel or ignore stale requests internally when a component unmounts or a query key changes — this is one of the concrete reasons those libraries exist rather than being a thin wrapper over fetch.
Verifying the fix
- Throttle the network to "Slow 3G" in DevTools, trigger the fetch, then navigate away before it resolves. No warning should print, and the Network tab should show the request as cancelled, not completed.
-
grep -rn "useEffect" srcfor any effect that callsfetchwithout anAbortController— each one is a candidate for the same bug even if it hasn't surfaced yet. - For subscription-based effects, confirm the cleanup function actually unsubscribes — a cleanup that returns nothing, or that recreates the subscription without removing the old one, reintroduces the leak silently.
Related Articles
- Fix useEffect Running Twice in React 18 — Strict Mode
- Next.js Hydration Mismatch: 8 Fixes for App Router (2026)
- Why global-error.tsx Doesn't Fire in Next.js Dev Mode
- React Server Components Deep Dive
- Fix: Multiple GoTrueClient instances detected (Supabase)
Originally published at https://www.iloveblogs.blog
Top comments (0)