Can't Perform State Update on Unmounted Component Fix
Cancel async work in useEffect cleanup with AbortController — React 18 removed the warning, but the leak and race remain..
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓Comfort with useState and useEffect basics
- ✓A React 18 app with route navigation
- ✓Familiarity with fetch and promises
- The fetch or timer outlives the component, so its .then calls setState after unmount. Cancel it in the useEffect cleanup instead of guarding with a flag.
- Pass an AbortController signal to fetch and call controller.abort() in cleanup. Ignore the resulting AbortError so it never surfaces as a failure.
- Don't use an isMounted ref to skip setState. It hides the warning while the request, subscription, and closure all keep leaking.
- React 18 removed the console warning entirely, so silence proves nothing. The stale update and wasted work still happen.
Think of a food delivery order you cancel by moving houses, but the driver shows up anyway and rings the bell of your empty old place. Your component is the house you moved out of, and the fetch is the driver still on the road. AbortController phones the restaurant to cancel the order, and useEffect cleanup is moving day — the right moment to call. An isMounted flag just tells the driver to knock quietly, but the trip and the fuel still happened.
You click into a user profile, click back before it loads, and the console scolds you: can't perform a React state update on an unmounted component. Nothing visibly breaks, so it's tempting to shrug. But that message is describing a real leak — work you paid for that delivers to nobody.
The mechanics are simple. Your effect fires a fetch, the user navigates away, the component unmounts, and then the promise resolves. Its .then callback still holds a reference to the state setter, so it calls setState on a component React already tore down. Before React 18 you got the warning. After React 18 you get silence and the same wasted work.
This bites hardest in exactly the apps you'd expect: search-as-you-type, tabbed dashboards, paginated tables, and any route that fetches on mount. Fast navigation turns every one of those into a race between unmount and resolution, and the loser writes state into the void.
This guide shows the full picture. You'll learn why async outlives unmount, how AbortController and effect cleanup cancel it for real, why the isMounted flag only hides the evidence, and what React 18's removed warning means for your debugging habits.
Why Async Resolving After Unmount Triggers the Warning
The warning fires on a strict timeline. Your component mounts, its effect starts async work — a fetch, a timeout, a subscription — and before that work finishes, the user navigates away. React unmounts the component, tears down its state hooks, and moves on. Then the promise resolves and the .then callback runs, calling a state setter that no longer has a live component behind it.
That setter isn't magic. It's a closure captured when the effect ran, holding a reference to the component's hook cell. After unmount, calling it can't trigger a re-render because there's nothing to render. Before React 18, React detected this and logged the warning. It never crashed your app, which is exactly why teams ignored it — the failure mode is wasted work and potential stale writes, not an exception.
Where this turns from waste into corruption is the remount case. The user navigates back, a fresh instance mounts with fresh state, and then the old request resolves and writes its stale payload into the new instance's state if the closure is shared through a store or a lifted setter. Search boxes, autocomplete, and tabbed dashboards hit this constantly because their requests overlap by design.
The takeaway is structural: any async work started in an effect must be tied to that effect's lifetime. If the effect can be torn down before the work finishes — and in React, it always can — you need a cancellation path. The next sections build exactly that.
AbortController Cancels Fetch When the Component Unmounts
AbortController is the web platform's standard cancellation primitive, and fetch understands it natively. You create a controller, hand its signal to fetch, and call abort() when the work is no longer wanted. The pending fetch rejects with an error whose name is AbortError, which you catch and deliberately ignore — it isn't a failure, it's a successful cancellation.
The pattern slots perfectly into useEffect. Create the controller inside the effect so each run gets its own, pass the signal to every fetch that run starts, and return a cleanup function that aborts. When the component unmounts — or when dependencies change and React re-runs the effect — the previous controller aborts, the old request dies, and only the current run's response can reach setState.
Two details trip people up. First, abort() rejects the promise, so you must handle AbortError or you'll trade one console warning for an unhandled rejection. Check err.name === 'AbortError' and return early. Second, libraries differ: axios uses the same signal option in recent versions, while older code uses CancelToken. Check your HTTP client's docs, but the shape is identical — a token in, a cancel call in cleanup.
This isn't just about silence. Aborting actually frees the socket, drops the response bytes, and lets the closure garbage-collect. It's the difference between muting a fire alarm and putting out the fire.
useEffect Cleanup Is the Only Safe Place to Cancel Work
The cleanup function is the only code React guarantees to run when an effect's lifetime ends. It runs before the effect re-runs on dependency changes and when the component unmounts. That makes it the single correct place to release everything the effect acquired: abort controllers, timer handles, subscription handles, and event listeners.
The most common mistake is splitting ownership — starting work in the effect body but cleaning it in a different effect, a click handler, or not at all. When ownership splits, at least one path skips cleanup. Keep it adjacent: the line that creates the resource and the line that releases it should live in the same effect, ideally within ten lines of each other so review catches gaps.
Cleanup also runs in StrictMode development double-invocation, which is a feature, not a bug. React mounts, cleans up, and re-runs effects to surface missing cleanup early. If your effect can't survive that cycle — if the second run doubles subscriptions or the cleanup crashes — production navigation will break it too. Treat StrictMode complaints as free bug reports.
Apply the same discipline beyond fetch. setTimeout needs clearTimeout, setInterval needs clearInterval, addEventListener needs removeEventListener, and socket.subscribe needs unsubscribe. Whatever verb opened the resource, its opposite belongs in the return statement.
Why the isMounted Flag Is an Anti-Pattern
The isMounted pattern looks like this: a ref set to true on mount and false in cleanup, with every setState wrapped in if (mountedRef.current). The console goes quiet, the ticket gets closed, and the leak continues. The fetch still crosses the network, the timer still fires, the subscription still pushes — you've only taught the callback to stay silent about it.
Worse, the flag actively hides races. Two overlapping requests both check the same ref, both find it true, and both write state in arrival order. The guard was never designed for ordering; it only knows mounted versus unmounted. So the stale response still overwrites the fresh one, and because the warning is gone, nobody investigates.
There's a subtler cost: the ref keeps the closure's world alive. The .then callback holds the ref, the ref lives on the component instance, and the instance's context — props, large payloads, store references — can't garbage-collect until the request settles. On route churn this adds up to real megabytes, exactly the growth teams chase with heap snapshots.
The React team removed the warning in React 18 partly for this reason: it trained developers to add flags instead of cleanup. The recommended fix hasn't changed — cancel the work itself. When nothing is left running after unmount, there's nothing to guard, and the flag becomes dead code you can delete.
React 18 Removed the Warning, Not the Memory Leak
React 18 removed the unmounted-component warning outright. If you upgrade and the console goes quiet, that's the framework change, not proof your effects are clean. The underlying behavior — promises resolving into dead closures — works exactly as before, minus the announcement.
The removal happened because the warning misfired and misled. It fired for harmless cases like setting state after a component unmounted during a Suspense transition, and it pushed developers toward isMounted flags that hid real leaks. The React team decided a warning that teaches the wrong fix is worse than no warning, and they were right.
So where does the signal come from now? Your own discipline. Test navigation under throttled network and watch for post-unmount resolutions. Take heap snapshots across route churn and confirm retained size stays flat. Add a lint rule against mounted refs. These checks replace the console message with something better: verification instead of vibes.
There's an upside. Without the warning to chase, teams stop playing whack-a-mole with setters and start designing cancellation properly — AbortController per effect, cleanup per resource, ordering per request stream. The apps that do this are quieter than React 17 apps ever were, and their quiet is earned.
Race Conditions When Two Requests Fight Over One State
Unmounts get the headlines, but overlapping requests cause more user-visible damage. Type a three-letter query fast and you fire three requests. The network doesn't guarantee order, so the one-letter response can arrive last and overwrite the three-letter results. The user sees answers to a question they stopped asking.
Cleanup alone doesn't solve this, because all three requests belong to the same mounted component — no unmount happens between keystrokes. What solves it is ordering: each effect run aborts the previous run's request when dependencies change. React runs the old cleanup before the new effect, so the previous controller aborts exactly when the next query starts. Only the newest request survives.
Abort isn't always enough, though. Cached responses, non-abortable clients, or requests that already reached the server can still resolve out of order. That's where the sequence counter helps: stamp each request with an incrementing number and let only the highest number write state. Belt and suspenders — abort what you can, ignore what you can't.
Add debouncing to cut volume, not to fix ordering. A 100–200 ms delay collapses a burst of keystrokes into one request, which saves backend cost. But a debounced request can still race the next one, so keep the abort and the sequence check regardless. Volume control and correctness are separate jobs.
Search Results Lied for Two Weeks While an isMounted Flag Hid the Leak
- An isMounted flag converts a loud warning into a silent leak. If your fix doesn't cancel work, it isn't a fix — it's a mute button.
- Every overlapping-request UI needs ordering, not just fetching. Abort or sequence your responses or the slowest request wins.
- When a framework removes a warning, re-add the check yourself. A navigation test under throttled network catches what the console no longer reports.
| File | Command / Code | Purpose |
|---|---|---|
| user-profile-fetch.js | export function UserProfile({ userId }) { | AbortController Cancels Fetch When the Component Unmounts |
| effect-cleanup.js | export function LiveFeed({ topic }) { | useEffect Cleanup Is the Only Safe Place to Cancel Work |
| abort-search.js | export function SearchBox() { | React 18 Removed the Warning, Not the Memory Leak |
| race-guard.js | export function Typeahead({ query }) { | Race Conditions When Two Requests Fight Over One State |
Key takeaways
Common mistakes to avoid
5 patternsStarting a fetch in one effect and cleaning it up in another
Creating an AbortController but never wiring its signal
controller.abort() in cleanup. Catch the rejection and return early on AbortError instead of setting error state.Adding an isMounted ref to silence the warning
Cleaning up fetch but forgetting timers and subscriptions
Ignoring race conditions between overlapping requests
Interview Questions on This Topic
What causes 'Can't perform a React state update on an unmounted component'?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's React.js. Mark it forged?
6 min read · try the examples if you haven't