Home › JavaScript › Can't Perform State Update on Unmounted Component Fix
Intermediate 6 min · September 23, 2026

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..

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
✓ Production
production tested
September 24, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 13 min
  • ✓Comfort with useState and useEffect basics
  • ✓A React 18 app with route navigation
  • ✓Familiarity with fetch and promises
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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.
✦ Definition~90s read
What is React Unmounted State Update Fix?

The unmounted-component state update is what happens when async work started by a component finishes after that component is gone. A fetch, timeout, interval, or subscription outlives its owner, and its callback invokes a state setter tied to a torn-down hook cell. React can't re-render an unmounted component, so the update goes nowhere — but the work already cost network, memory, and CPU.

★
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.

Three pieces combine to produce it. First, an effect that launches async work without tying the work's lifetime to the effect's lifetime. Second, a navigation or conditional render that unmounts the component before the work settles — common in search boxes, tabs, modals, and paginated views.

Third, a callback that unconditionally writes the result into state, with no cancellation or ordering check.

What it is NOT matters for choosing the fix. It isn't a rendering bug — your JSX is fine. It isn't a state-management bug — no store shape change helps. It isn't fixed by guarding the setter with a mounted flag, because the flag leaves the underlying work running. And since React 18, it isn't even a warning anymore, so its absence tells you nothing about its presence.

Think of it as ordering food to a hotel room after checkout. The kitchen still cooks, the courier still drives, and the bill still lands — but there's no guest to eat. AbortController cancels the order, effect cleanup checks out at the right moment, and sequence numbers make sure only the current guest's meal gets served.

Plain-English First

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.

📊 Production Insight
On-call teams waste hours because the warning names the setter, not the request. Log which fetch resolved post-unmount and the culprit effect identifies itself.
🎯 Key Takeaway
The .then callback outlives the component and calls a setter with nothing to re-render. Tie every async task to its effect's lifetime or it writes into the void.

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.

user-profile-fetch.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { useEffect, useState } from 'react';

export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      try {
        const res = await fetch('/api/users/' + userId, {
          signal: controller.signal
        });
        if (!res.ok) throw new Error('request failed: ' + res.status);
        setUser(await res.json());
      } catch (err) {
        if (err.name === 'AbortError') return; // unmounted: expected
        setError(err.message);
      }
    }

    load();
    return () => controller.abort(); // cancel on unmount or userId change
  }, [userId]);

  if (error) return <p>Failed: {error}</p>;
  if (!user) return <p>Loading…</p>;
  return <h1>{user.name}</h1>;
}
Try it live
📊 Production Insight
Teams that abort in cleanup see measurable drops in API bills on search-heavy pages, because abandoned keystroke requests stop consuming backend time.
🎯 Key Takeaway
Create the controller in the effect, pass its signal to fetch, abort in cleanup, and ignore AbortError. The request truly dies instead of resolving nowhere.

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.

effect-cleanup.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useEffect, useState } from 'react';

export function LiveFeed({ topic }) {
  const [items, setItems] = useState([]);

  useEffect(() => {
    const controller = new AbortController();
    const timer = setTimeout(() => {
      fetch('/api/feed?topic=' + topic, { signal: controller.signal })
        .then((r) => r.json())
        .then(setItems)
        .catch((err) => {
          if (err.name !== 'AbortError') console.error(err);
        });
    }, 500);

    return () => {
      clearTimeout(timer); // cancel the delayed start
      controller.abort(); // cancel the fetch itself
    };
  }, [topic]);

  return (
    <ul>
      {items.map((i) => <li key={i.id}>{i.title}</li>)}
    </ul>
  );
}
Try it live
📊 Production Insight
Splitting start and cleanup across effects is the top source of unmount bugs in review. Colocate them and half your leak reports disappear.
🎯 Key Takeaway
Return cleanup from the same effect that starts the work. StrictMode double-invocation tests your cleanup for free — listen to it.

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.

⚠ isMounted Guards Mute the Alarm, They Don't Stop the Fire
If your fix doesn't cancel work, it isn't a fix. Delete the mounted ref and abort, clear, or unsubscribe the underlying resource instead.
📊 Production Insight
Codebases that ban mounted refs in lint see fewer stale-data tickets within a quarter, because every fix starts canceling work instead of hiding updates.
🎯 Key Takeaway
An isMounted ref skips the setter but keeps the cost. Cancel the request, timer, or subscription itself and the guard becomes unnecessary.

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.

abort-search.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useEffect, useState } from 'react';

export function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) { setResults([]); return; }
    const controller = new AbortController();
    const id = setTimeout(() => {
      fetch('/api/search?q=' + encodeURIComponent(query), {
        signal: controller.signal
      })
        .then((r) => r.json())
        .then(setResults)
        .catch((err) => {
          if (err.name !== 'AbortError') console.error(err);
        });
    }, 120);

    return () => {
      clearTimeout(id);
      controller.abort();
    };
  }, [query]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Try it live
📊 Production Insight
Post-upgrade, the teams that catch leaks fastest are the ones that throttle to Slow 3G in QA — the timing window that exposes every missing abort.
🎯 Key Takeaway
Silence after upgrading proves nothing. Replace the removed warning with navigation tests, heap snapshots, and lint rules that verify cleanup.

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.

race-guard.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { useEffect, useRef, useState } from 'react';

export function Typeahead({ query }) {
  const [hits, setHits] = useState([]);
  const latest = useRef(0);

  useEffect(() => {
    if (!query) { setHits([]); return; }
    const seq = ++latest.current;
    const controller = new AbortController();

    fetch('/api/search?q=' + encodeURIComponent(query), {
      signal: controller.signal
    })
      .then((r) => r.json())
      .then((data) => {
        if (seq === latest.current) setHits(data); // newest wins
      })
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort();
  }, [query]);

  return <ul>{hits.map((h) => <li key={h.id}>{h.name}</li>)}</ul>;
}
Try it live
📊 Production Insight
Search teams that add sequence checks discover their 'backend relevance bug' was a frontend race all along — relevance jumps without touching ranking.
🎯 Key Takeaway
Abort the previous request on every change and accept only the newest response. Debouncing cuts volume; cancellation guarantees ordering.
● Production incidentPOST-MORTEMseverity: high

Search Results Lied for Two Weeks While an isMounted Flag Hid the Leak

Symptom
Users typed fast in global search and saw results for an older query about one time in five. Navigating away mid-search left pending requests that resolved nowhere, and the page's memory footprint grew roughly 8 MB per ten searches.
Assumption
The team assumed the warning was cosmetic because nothing visibly broke and React 18 later removed it entirely. Their isMounted guard seemed to handle it, and the search page passed every functional test.
Root cause
The search box fired a fetch per keystroke with no AbortController and guarded setState with an isMounted ref. Slow responses for older queries routinely resolved after newer ones and overwrote correct results; on fast navigation they resolved after unmount into dead closures. After the React 18 upgrade the console warning vanished, so the team lost their only signal while the race kept corrupting results.
Fix
Each keystroke now aborts the previous request through an AbortController owned by the effect, and cleanup aborts on unmount. A 120 ms debounce cut request volume, a sequence check keeps only the newest response, and the isMounted ref was deleted. A navigation test under Slow 3G asserts zero post-unmount setState calls.
Key lesson
  • 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.
Production debug guideFive checks that find the leaking effect and prove the cleanup fixed it.5 entries
Symptom · 01
Warning appears on fast navigation but you can't pin down which component
→
Fix
Open DevTools Network, throttle to Slow 3G, load the route, and navigate away before the request finishes. If the request completes and your .then logging still prints, you've reproduced it. The fix starts from that repro, not from the console.
Symptom · 02
No warning in React 18, but memory climbs on route churn
→
Fix
Search the codebase for fetch, axios, setTimeout, setInterval, and .subscribe inside useEffect. For each hit, check whether the effect returns a cleanup that aborts, clears, or unsubscribes. Any effect with no return is a suspect.
Symptom · 03
An isMounted flag silences the console but results still feel stale
→
Fix
Read the effect and look for let alive = true or useRef(false) guards around setState. If the guard exists but no abort or unsubscribe exists alongside it, it's the anti-pattern. The request still runs; only the setter is skipped.
Symptom · 04
Search results settle on the wrong query
→
Fix
Type quickly in the search box and watch whether results match your final query. If an older query's results flash in last, you have a race, not just a leak. Add abort-on-change and confirm the UI settles on the newest query every time.
Symptom · 05
You need proof the fix worked before closing the ticket
→
Fix
Record a heap snapshot, navigate between two data routes ten times, and snapshot again. Growing detached closures or duplicate pending requests confirm leaked async work. After adding cleanup, repeat: retained size should stay flat.
Unmounted State Update Causes Compared
Root CauseHow to ConfirmFixPrevention
Fetch resolves after unmountAdd a console.log in .then and navigate away before it resolves; it still printsAbortController signal plus abort() in useEffect cleanupWrap data fetching in a hook that always aborts on cleanup
Timer or interval keeps firingComponent unmounts but network tab or logs show continued activityclearTimeout or clearInterval in the effect cleanupKeep timer handles in a ref owned by the starting effect
isMounted guard hides the updateSearch for isMounted or mountedRef in the codebaseRemove the flag and cancel the work itselfLint against mutable mounted refs in code review
Overlapping requests raceType fast in a search box; results match an older querySequence numbers or abort previous request per keystrokeDebounce input and cancel in-flight requests on change
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
user-profile-fetch.jsexport function UserProfile({ userId }) {AbortController Cancels Fetch When the Component Unmounts
effect-cleanup.jsexport function LiveFeed({ topic }) {useEffect Cleanup Is the Only Safe Place to Cancel Work
abort-search.jsexport function SearchBox() {React 18 Removed the Warning, Not the Memory Leak
race-guard.jsexport function Typeahead({ query }) {Race Conditions When Two Requests Fight Over One State

Key takeaways

1
Async work outlives unmounted components, and its .then still calls setState into the void.
2
Cancel fetches with AbortController in useEffect cleanup; ignore the resulting AbortError.
3
The isMounted flag hides the warning while the request, timer, and closure keep leaking.
4
React 18 removed the warning, not the leak
silence isn't proof of health.
5
Overlapping requests race, so abort the previous one or accept only the latest response.
6
One effect should own one resource, and its cleanup must release exactly that resource.

Common mistakes to avoid

5 patterns
×

Starting a fetch in one effect and cleaning it up in another

Symptom
Cleanup never runs for the right request, and state updates land after unmount on fast navigation.
Fix
Return the cleanup from the same effect that starts the work, and abort or unsubscribe there. One effect owns one resource, so nothing outlives the component.
×

Creating an AbortController but never wiring its signal

Symptom
No warning before React 18, no crash after it — just silent wasted bandwidth and a loading spinner that resolves nowhere.
Fix
Pass the signal into fetch and call 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

Symptom
Console goes quiet but memory climbs on route churn, and stale responses still overwrite fresh ones when you navigate back.
Fix
Delete the ref and cancel the underlying work with AbortController, clearTimeout, or unsubscribe. If nothing is left running, there's nothing to guard.
×

Cleaning up fetch but forgetting timers and subscriptions

Symptom
A dashboard polls every 5 seconds and keeps polling after unmount, stacking intervals each time the route remounts.
Fix
Treat every setTimeout, setInterval, and subscription like a fetch: store the handle and clear it in cleanup. Better yet, drive polling from the effect with chained timeouts you can cancel.
×

Ignoring race conditions between overlapping requests

Symptom
Search results flicker and settle on the wrong query because a slow first request resolves after a fast second one.
Fix
Track the latest request with a sequence number or a fresh AbortController per effect run, and ignore every response except the newest. Cleanup aborts the previous run automatically.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What causes 'Can't perform a React state update on an unmounted componen...
Q02SENIOR
How does AbortController fix this properly?
Q03SENIOR
Why is the isMounted flag considered an anti-pattern?
Q04SENIOR
What changed in React 18, and what stays broken if you ignore cleanup?
Q05SENIOR
How do you stop a slow search response from overwriting a fast one?
Q01 of 05JUNIOR

What causes 'Can't perform a React state update on an unmounted component'?

ANSWER
The component unmounted while a promise was pending, and the .then callback called a state setter on a component with no mounted instance. React can't re-render something that's gone, so it warns. The durable fix is canceling the work in the effect cleanup with AbortController, not guarding the setter.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is this warning just noise I can suppress?
02
Why don't I see this warning in React 18?
03
Does an isMounted ref fix the underlying problem?
04
What if the data is needed but the component unmounts quickly?
05
Can this bug corrupt state even when nothing unmounts?
06
Do subscriptions and event listeners need the same cleanup?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
✓ Verified
production tested
September 24, 2026
last updated
1,942
articles · all by Naren
🔥

That's React.js. Mark it forged?

6 min read · try the examples if you haven't

←
Previous
DOMException play() Failed Fix
51 / 52 · React.js
Next
localStorage SecurityError Fix
→