Skip to content

Fix: React Can't Perform a State Update on an Unmounted Component

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix the React warning 'Can't perform a React state update on an unmounted component' caused by async operations, subscriptions, or timers.

Can’t perform a state update on an unmounted component

The version of this bug I keep meeting is not the warning itself but its absence: a team upgrades to React 18, the console goes quiet, and everyone assumes the leaks were fixed. I learned to distrust that silence the day I watched a dashboard’s memory climb for hours because an unmounted chart component was still polling an API every five seconds, invisibly. In my experience the mental model that actually prevents this is brutally simple, anything an effect starts must be stopped by that effect’s cleanup, and if you cannot point at the line in the cleanup that cancels the line in the body, the bug is already there whether or not React says so.

You open the browser console and see this warning from React:

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.

This warning means a component called setState (or a state setter from useState) after it was already removed from the DOM. The state update itself does nothing, React ignores it. Whether that matters depends on what triggered it. A one-shot fetch that resolved a moment after unmount is genuinely harmless: the callback runs once, the no-op happens, everything gets garbage-collected. But when the trigger is an interval, an event listener, or an open WebSocket, the thing firing the updates keeps running forever, and that is a real leak, wasted CPU, accumulating handlers, connections that never close.

The warning appears in React 16 and 17. In React 18 it was removed, partly because its “indicates a memory leak” wording was wrong for the harmless one-shot case and pushed people toward worse fixes. But the dangerous cases, timers, listeners, subscriptions without cleanup, are exactly as broken on React 18 as before; React just stopped telling you. You still need to clean up.

What Outlives the Component

React components have a lifecycle. They mount (appear in the DOM), update (re-render with new props or state), and unmount (get removed from the DOM). When a component unmounts, any running asynchronous operations, fetch requests, timers, event listeners, WebSocket connections, do not automatically stop. JavaScript has no way of knowing that the component that started them no longer exists.

Here is the typical sequence that triggers this warning:

  1. A component mounts and kicks off an async operation (a fetch call, a setTimeout, a subscription).
  2. The user navigates away, or a parent component conditionally removes this component from the tree.
  3. The component unmounts. React destroys its internal state.
  4. The async operation completes and calls setState on the now-unmounted component.
  5. React detects the update targets a component that no longer exists, and logs the warning.

The root cause is always the same: something that outlives the component is trying to update its state. The fix is always some form of cleanup, canceling, aborting, or ignoring the result of that operation when the component unmounts.

This problem is closely related to useEffect infinite loops, which also stem from effects that lack proper dependency management or cleanup. The difference is that an infinite loop hammers the component while it is mounted, while this warning fires after the component is gone.

Diagnostic Timeline

Here is how an experienced React developer actually tracks down the leak, especially in React 18 where the console is silent but the bug is not.

Minute 0, first reaction: “this warning was removed in React 18, ignore it.” Or worse: “we upgraded to React 18 and the warning stopped, so we fixed it.” This is the most expensive mistake. React 18 removed the warning because the React team judged it noisy, not because the underlying problem went away. Memory leaks, race conditions, wasted network requests, and zombie WebSockets all still happen, React just stopped telling you. If your app’s memory profile rises over time in production, the leak is here even when the console is clean.

Minute 1, second wrong suspicion: “it’s StrictMode causing double calls.” React 18 StrictMode mounts every component twice in development to deliberately expose cleanup bugs. Many developers see doubled fetch requests, blame StrictMode, and disable it. StrictMode is not the bug, it is the test for the bug. If your code shows doubled side effects under StrictMode, your cleanup function is missing or broken. Disabling StrictMode hides the symptom and the bug ships to production.

Minute 2, third wrong suspicion: “the warning is wrong, my code is fine.” You add console.log('cleanup ran') to your effect’s return function. You see it log. You assume cleanup works. But running cleanup does not mean cleanup cancels the side effect. Compare:

// Cleanup runs, but the fetch already resolved 50ms ago
useEffect(() => {
  fetch('/api/data').then(d => setData(d));
  return () => console.log('cleanup'); // does nothing useful
}, []);

vs.

useEffect(() => {
  const ctrl = new AbortController();
  fetch('/api/data', { signal: ctrl.signal }).then(d => setData(d));
  return () => ctrl.abort(); // actually cancels the request
}, []);

A cleanup that does not call abort(), clearInterval(), removeEventListener(), unsubscribe(), or close() is decorative. It runs and accomplishes nothing.

Minute 3, find the leaking effect with the browser’s memory tools. Open Chrome DevTools > Memory tab > take a heap snapshot. Navigate away from the suspected component. Take a second snapshot. Compare snapshots and filter by your component’s class or function name. If instances remain after navigation, something is holding a reference, usually a closure inside a still-running setInterval, an active fetch, or an event listener attached to window.

For React-specific tracking, use the Components tab in React DevTools. Unmounted components should disappear from the tree. If you suspect a memory leak but cannot pinpoint it, install why-did-you-render or use Chrome’s Performance tab and record while triggering mount/unmount cycles. Look for detached DOM nodes, these are the smoking gun.

Minute 4, fourth wrong suspicion: “I’ll just use the isMounted flag.” This is the worst common fix. It silences the warning by skipping the setState call but does nothing to cancel the underlying work. The fetch still downloads. The interval still fires. The WebSocket still holds an open connection. You spent CPU and network for a response your component throws away. Use AbortController for fetch/axios, clearInterval for timers, removeEventListener for DOM events, and unsubscribe() for observables. The flag pattern is a last resort for libraries with no cancellation API.

Minute 5, actual root cause discovery. By this point you have one of four leak shapes:

  1. Fetch without AbortController, most common. Fix with the controller pattern.
  2. Timer without clearTimeout/clearInterval, fires forever, holds the component in memory through its closure.
  3. Event listener without removeEventListener, accumulates one per mount cycle. After 100 navigations the listener fires 100 times per event.
  4. Subscription (WebSocket, Firebase, RxJS, EventSource) without explicit teardown, holds an open connection forever and processes incoming messages.

Each fix is below. The one universal rule: if an effect starts something, its cleanup must stop that exact thing. If you cannot point to a line in the cleanup that undoes a line in the effect body, the cleanup is incomplete.

Fix 1: Cleanup Function in useEffect

The useEffect hook accepts a cleanup function, the function you return from the effect callback. React calls this cleanup function when the component unmounts, and also before re-running the effect if its dependencies change. This is where you cancel anything the effect started.

Broken code, no cleanup:

function Notifications() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const interval = setInterval(() => {
      fetch('/api/notifications')
        .then(res => res.json())
        .then(data => setMessages(data));
    }, 5000);
  }, []);

  return <ul>{messages.map(m => <li key={m.id}>{m.text}</li>)}</ul>;
}

If this component unmounts (the user navigates to a different page), the setInterval keeps running. Every 5 seconds, it fetches data and calls setMessages on a component that no longer exists.

Fix, return a cleanup function that clears the interval:

useEffect(() => {
  const interval = setInterval(() => {
    fetch('/api/notifications')
      .then(res => res.json())
      .then(data => setMessages(data));
  }, 5000);

  return () => clearInterval(interval);
}, []);

The cleanup function () => clearInterval(interval) runs when the component unmounts, stopping the interval and preventing any further state updates.

Every useEffect that creates a side effect, a timer, a listener, a subscription, a request, should return a cleanup function that tears it down. If your effect does not return anything, ask yourself whether it starts something that could outlive the component.

Fix 2: AbortController for Fetch Requests

Fetch requests are the most common source of this warning. A component fires off a request, the user navigates away, and the response arrives after unmount. The .then() handler calls setState, triggering the warning.

Broken code:

function UserProfile({ userId }) {
  const [profile, setProfile] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to load');
        return res.json();
      })
      .then(data => setProfile(data))
      .catch(err => setError(err.message));
  }, [userId]);

  if (error) return <p>Error: {error}</p>;
  if (!profile) return <p>Loading...</p>;
  return <h1>{profile.name}</h1>;
}

If the user navigates away while the request is in flight, setProfile or setError will be called on an unmounted component.

Fix, use AbortController to cancel the request on unmount:

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

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => {
      if (!res.ok) throw new Error('Failed to load');
      return res.json();
    })
    .then(data => setProfile(data))
    .catch(err => {
      if (err.name === 'AbortError') {
        // Request was cancelled — component unmounted, do nothing
        return;
      }
      setError(err.message);
    });

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

When the cleanup function calls controller.abort(), the fetch promise rejects with an AbortError. The catch handler checks for this specific error name and silently ignores it. Any other error (network failure, server error) is still handled normally.

This also fixes race conditions. If userId changes rapidly, each new effect run aborts the previous request before starting a new one. Only the response for the latest userId updates state. Without this, responses could arrive out of order, and the component might display stale data from an earlier request, a subtle bug that is hard to reproduce and frequently misreported as “intermittent flicker” or “the wrong user shows up sometimes.”

If you are using axios instead of the native fetch API, pass the signal in the config object the same way:

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

  axios.get(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => setProfile(res.data))
    .catch(err => {
      if (axios.isCancel(err)) return;
      setError(err.message);
    });

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

The stakes here are larger than a console warning. An unaborted fetch still downloads its full response, still holds the connection, and can still overwrite fresh data with a stale reply that arrives late. On mobile, that is real bandwidth and battery spent on responses your UI throws away, which is why I treat AbortController as part of writing the fetch, not as an optional cleanup nicety.

Fix 3: Clearing Timers and Intervals

setTimeout and setInterval are fire-and-forget by default. If you do not clear them, they will execute their callback regardless of whether the component that created them still exists.

Broken code, setTimeout without cleanup:

function DelayedMessage() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    setTimeout(() => {
      setShow(true); // Fires even if component unmounted
    }, 3000);
  }, []);

  return show ? <p>Hello!</p> : null;
}

Fix, store the timer ID and clear it on cleanup:

useEffect(() => {
  const timerId = setTimeout(() => {
    setShow(true);
  }, 3000);

  return () => clearTimeout(timerId);
}, []);

The same pattern applies to setInterval:

useEffect(() => {
  const intervalId = setInterval(() => {
    setCount(prev => prev + 1);
  }, 1000);

  return () => clearInterval(intervalId);
}, []);

If you have multiple timers, clear all of them in the cleanup:

useEffect(() => {
  const timer1 = setTimeout(() => setStep(1), 1000);
  const timer2 = setTimeout(() => setStep(2), 2000);
  const timer3 = setTimeout(() => setStep(3), 3000);

  return () => {
    clearTimeout(timer1);
    clearTimeout(timer2);
    clearTimeout(timer3);
  };
}, []);

If your project uses a linting setup and you see parse errors from ESLint when adding these cleanup functions, check ESLint parsing error for configuration fixes.

Fix 4: Unsubscribing from Event Listeners

Event listeners attached to window, document, or any DOM element outside the component tree will persist after the component unmounts unless you explicitly remove them.

Broken code:

function WindowSize() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    window.addEventListener('resize', () => {
      setWidth(window.innerWidth);
    });
  }, []);

  return <p>Width: {width}px</p>;
}

After unmount, the resize listener is still active. Every resize event calls setWidth on a dead component. Over time, if this component mounts and unmounts repeatedly, listeners accumulate.

Fix, store a reference to the handler and remove it on cleanup:

useEffect(() => {
  const handleResize = () => {
    setWidth(window.innerWidth);
  };

  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

You must pass the same function reference to both addEventListener and removeEventListener. An anonymous arrow function will not work because removeEventListener cannot match it to the one that was added.

Fix 5: Unsubscribing from WebSockets and External Subscriptions

WebSocket connections, Firebase listeners, RxJS observables, and other subscription-based APIs all require explicit teardown.

Broken code, WebSocket without cleanup:

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

  useEffect(() => {
    const ws = new WebSocket('wss://api.example.com/feed');

    ws.onmessage = (event) => {
      const item = JSON.parse(event.data);
      setItems(prev => [...prev, item]);
    };
  }, []);

  return <ul>{items.map(item => <li key={item.id}>{item.text}</li>)}</ul>;
}

The WebSocket stays open after unmount. Messages continue arriving and calling setItems.

Fix, close the WebSocket on cleanup:

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/feed');

  ws.onmessage = (event) => {
    const item = JSON.parse(event.data);
    setItems(prev => [...prev, item]);
  };

  ws.onerror = (error) => {
    console.error('WebSocket error:', error);
  };

  return () => {
    ws.close();
  };
}, []);

For Firebase Firestore:

useEffect(() => {
  const unsubscribe = onSnapshot(
    collection(db, 'messages'),
    (snapshot) => {
      const msgs = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
      setMessages(msgs);
    }
  );

  return () => unsubscribe();
}, []);

For RxJS observables:

useEffect(() => {
  const subscription = dataStream$.subscribe(data => {
    setData(data);
  });

  return () => subscription.unsubscribe();
}, []);

The pattern is universal: subscribe in the effect body, unsubscribe in the cleanup function.

Fix 6: React 18, Warning Removed but the Leak Remains

React 18 removed the “Can’t perform a state update on an unmounted component” warning. The React team decided the warning caused more confusion than it prevented bugs, because in many cases the state update on an unmounted component is harmless (it’s a no-op). However, the underlying issue, resource leaks, is still real.

If you are on React 18 or later, you will not see the warning in your console. But your code can still:

  • Hold open WebSocket connections after unmount.
  • Keep intervals running, consuming CPU.
  • Leave event listeners attached, accumulating on every mount/unmount cycle.
  • Complete fetch requests and process their responses unnecessarily.
  • Cause race conditions when async operations resolve out of order.

React 18 Strict Mode makes these problems more visible. In development, Strict Mode mounts every component twice (mount, unmount, mount again). This deliberately triggers cleanup functions to verify they work correctly. If your effect does not have a cleanup function, Strict Mode will expose the bug, you will see doubled fetch requests, doubled event listeners, or doubled WebSocket connections.

The fix is the same as before: always return a cleanup function from effects that create side effects. Do not rely on the absence of the warning to assume your code is correct.

// Correct in React 16, 17, and 18
useEffect(() => {
  const controller = new AbortController();

  fetchData(controller.signal)
    .then(data => setData(data))
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

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

Fix 7: The isMounted Anti-Pattern vs Proper Cleanup

You may encounter advice suggesting an isMounted flag to prevent state updates after unmount:

// Anti-pattern — works but masks the real problem
useEffect(() => {
  let isMounted = true;

  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      if (isMounted) {
        setData(data);
      }
    });

  return () => {
    isMounted = false;
  };
}, []);

This suppresses the warning and prevents the state update, but it does not cancel the fetch request. The request still completes, the response is still downloaded, and the .then() callbacks still execute. The only thing the flag does is skip the setState call at the end.

For fetch requests, use AbortController instead. It actually cancels the network request, saving bandwidth and server resources:

// Correct — cancels the request entirely
useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => {
      if (err.name !== 'AbortError') console.error(err);
    });

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

The isMounted pattern is acceptable in limited situations where there is no way to cancel the underlying operation, for example, a third-party library that returns a promise with no cancellation mechanism. In those cases, the boolean flag is a reasonable workaround. But for fetch, timers, event listeners, and subscriptions, use the proper cancellation API.

If you are working in a TypeScript project and encounter type errors when setting up these patterns, see TypeScript type not assignable for guidance on annotating state setters and event handler types correctly.

Fix 8: Race Conditions with Async Effects

When an effect depends on a value that changes rapidly (search input, route params, selected item), multiple async operations can be in flight simultaneously. If the earlier operation completes after the later one, it overwrites the correct state with stale data. This is a race condition, and it often accompanies the unmounted component warning.

Broken code, race condition with rapidly changing input:

function Search({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    async function doSearch() {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = await res.json();
      setResults(data); // Might set stale results if query changed
    }
    doSearch();
  }, [query]);

  return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}

If the user types “react” quickly, five requests fire, one per keystroke: “r”, “re”, “rea”, “reac”, “react”. The response for “re” might arrive after “react”, overwriting the correct results.

Fix, combine AbortController with async/await:

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

  async function doSearch() {
    try {
      const res = await fetch(
        `/api/search?q=${encodeURIComponent(query)}`,
        { signal: controller.signal }
      );
      const data = await res.json();
      setResults(data);
    } catch (err) {
      if (err.name !== 'AbortError') {
        console.error('Search failed:', err);
      }
    }
  }

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

Each time query changes, the cleanup function aborts the previous request before the new effect runs. Only the latest request completes and updates state. This eliminates both the race condition and the unmounted component warning.

For more complex scenarios, consider using a data-fetching library like React Query (TanStack Query) or SWR. These libraries handle request cancellation, caching, deduplication, and race conditions out of the box:

import { useQuery } from '@tanstack/react-query';

function Search({ query }) {
  const { data: results = [] } = useQuery({
    queryKey: ['search', query],
    queryFn: ({ signal }) =>
      fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal })
        .then(res => res.json()),
    enabled: query.length > 0,
  });

  return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}

React Query automatically cancels in-flight requests when the query key changes, handles component unmounting, and provides caching. It solves this entire class of problems at a higher level of abstraction.

This is the trap I see teams walk into after a React 18 upgrade: the console goes quiet, everyone assumes the cleanup debt was somehow paid, and months later the production memory dashboards start trending upward. The leaks were there the whole time. React just stopped narrating them, which is exactly why the fix has to be habits and code review, not warning-driven whack-a-mole.

Leaks That Outlive the Obvious Fixes

Check for Multiple State Updates in One Async Flow

If your async function calls multiple state setters in sequence, each one could trigger the warning independently. Combine related state into a single object or use useReducer:

// Multiple separate state updates — each one can fail independently
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

// Better — single state update with useReducer
const [state, dispatch] = useReducer(reducer, { data: null, loading: true, error: null });

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

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => dispatch({ type: 'SUCCESS', data }))
    .catch(err => {
      if (err.name !== 'AbortError') {
        dispatch({ type: 'ERROR', error: err.message });
      }
    });

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

Trace Which Component Is Leaking

If the warning does not clearly identify the component (older React versions show the component name, newer ones may not), add a console.log inside each useEffect cleanup function:

useEffect(() => {
  console.log('MyComponent: effect running');
  return () => console.log('MyComponent: cleanup running');
}, []);

If cleanup never logs, the effect has no cleanup function, that is your leak. If cleanup logs but the warning still appears, the cleanup is not canceling everything the effect started.

Watch for Third-Party Library Subscriptions

Libraries like socket.io-client, firebase, graphql-ws, or redux-saga create their own subscriptions. These must be torn down in cleanup functions. Check the library documentation for its unsubscribe or disconnect method and call it in the cleanup.

Verify the Cleanup Actually Prevents the State Update

A cleanup function that runs but does not actually stop the side effect is useless. For example, calling clearTimeout with the wrong ID, or closing a WebSocket that was already replaced by a new one. Log inside the cleanup and inside the state update to confirm that cleanup runs before the stale update would occur.

Consider Lifting Async Logic Out of the Component

If a component frequently mounts and unmounts (modals, tabs, route transitions), consider moving the data fetching to a parent component that stays mounted, or to a global state management layer. The child component receives data via props and never manages its own async lifecycle. This sidesteps the unmounting problem entirely.

If you are experiencing hydration mismatches alongside this warning in a Next.js application, the two issues may be related, an effect running during server rendering can produce different HTML than the client expects, compounding the cleanup problem.

React Server Components: useEffect is not allowed at all

If you migrate a client component to a React Server Component (RSC), useEffect does not silently skip, it is a hard error. In Next.js App Router the build fails with “You’re importing a component that needs useEffect. This React hook only works in a Client Component,” and the fix is either adding "use client" at the top of the file or removing the effect. The useful flip side: an RSC that fetches data needs no cleanup at all, because there is no client lifecycle to leak across. If a component’s only effect was data fetching with cancellation, converting it to a server component deletes this entire class of bug.

Cleanup can run with no unmount and no user action

Two React 18 mechanisms run your cleanup even though nothing visibly unmounted. In development, StrictMode deliberately runs every effect’s mount-cleanup-mount cycle once to prove the cleanup works. And a Suspense boundary that re-suspends, showing its fallback again after content was already committed, cleans up the hidden tree’s effects and re-runs them when the content reappears (the React 18 changelog calls this out for layout effects). Note that an interrupted concurrent render never runs effects at all; effects fire only after commit, so “a higher-priority update discarded my render” is not where stray cleanups come from. Either way the rule is unchanged: cleanup must actually cancel the work, because React is allowed to run it at times you did not anticipate.

A long-lived callback holding a dead instance’s setter

State setters are per component instance. If you hand one to something that outlives the component, a global event bus, a module-level cache, a singleton service, that callback keeps calling the old instance’s setter after unmount: a no-op forever, even after the component remounts as a fresh instance with a fresh setter. No ref trick fixes this, because useRef is also per instance. The correct shape is the same subscribe/unsubscribe discipline as Fix 4 and Fix 5: register the callback in an effect (so each mounted instance registers its own current setter) and remove it in that effect’s cleanup, so nothing outside React ever holds a setter for an instance that no longer exists.


Related: Fix: useEffect runs infinitely | Fix: Hydration failed in Next.js | Fix: TypeScript type is not assignable | Fix: ESLint parsing error

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles