Skip to content

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

FixDevs ·

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.

The Error

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 does nothing — React ignores it — but the code that triggered it is still running in the background, consuming memory, holding network connections open, or executing logic that should have stopped when the component disappeared.

The warning appears in React 16 and 17. In React 18, the warning was removed from the console output, but the underlying problem still exists. Your component is leaking resources. The absence of the warning does not mean the bug is fixed — it means React stopped telling you about it. You still need to clean up.

Why This Happens

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.

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. See Node cannot find module if your server-side code for this endpoint fails to resolve dependencies.

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]);

Why this matters: Failing to abort fetch requests doesn’t just suppress a warning — it wastes bandwidth, keeps connections open, and can overwrite fresh data with stale responses. In mobile apps, this translates to unnecessary data usage and battery drain for your users.

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, four requests fire — for “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.

Real-world scenario: In React 18, the removal of this warning catches many teams off guard. They upgrade, the console goes quiet, and they assume the leaks are fixed. Months later, they notice mounting memory usage in production dashboards. The leaks were always there — React just stopped warning about them.

Still Not Working?

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.


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

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