Back to Discussions

React `useEffect` Race Condition: `Can't perform a React state update on an unmounted component` with Rapid Navigation

11 Views
Jul 19, 2026, 12:31:57 AM

I'm working on a performance monitoring dashboard application built with React (v18.2.0), utilizing axios for API calls. The dashboard features several tabs, each rendering a different PerformanceMetric component responsible for fetching and displaying specific time-series data (e.g., CPU utilization, memory consumption, network throughput) from our backend API. Users often navigate quickly between these tabs, triggering mounts and unmounts of the PerformanceMetric component.

Problem:

The PerformanceMetric component uses useEffect to fetch data when it mounts or when its metricId prop changes. We display a loading spinner while data is being fetched and an error message if the API call fails.

The issue arises when a user rapidly switches tabs. For instance, if a user navigates from "CPU Metrics" to "Memory Metrics" and then quickly back to "CPU Metrics", or to another tab and then logs out (unmounting all dashboard components), we frequently encounter the following warning in the console:

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.
    at PerformanceMetric (http://localhost:3000/static/js/main.chunk.js:1234:5)
    at div
    ... (truncated stack trace)

This warning points to the setData or setError calls within the useEffect's asynchronous callback. While it's currently just a warning, I'm concerned about potential memory leaks, inconsistent UI states, or even unexpected behavior if subsequent components rely on the data. My development environment is macOS Ventura 13.5, Chrome 118, Node.js 18.17.1.

Code:

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Spinner, Alert } from 'some-ui-library'; // Placeholder for UI components

interface PerformanceMetricProps {
  metricId: string;
  timeRange: string; // e.g., '1h', '24h', '7d'
}

interface MetricData {
  timestamp: number;
  value: number;
}

const PerformanceMetric: React.FC<PerformanceMetricProps> = ({ metricId, timeRange }) => {
  const [data, setData] = useState<MetricData[] | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);
    setData(null); // Clear previous data

    const fetchMetricData = async () => {
      try {
        const response = await axios.get<MetricData[]>(
          `/api/metrics/${metricId}?range=${timeRange}`
        );
        setData(response.data);
      } catch (err) {
        if (axios.isAxiosError(err)) {
          setError(err.message);
        } else {
          setError('An unknown error occurred.');
        }
      } finally {
        setLoading(false);
      }
    };

    fetchMetricData();

    // No cleanup function defined yet, this is where I suspect the problem lies.
    // What's the robust way to ensure state updates only happen on mounted components?
  }, [metricId, timeRange]);

  if (loading) {
    return <Spinner message={`Loading ${metricId} data...`} />;
  }

  if (error) {
    return <Alert type="error" message={`Failed to load ${metricId}: ${error}`} />;
  }

  if (!data || data.length === 0) {
    return <Alert type="info" message={`No data available for ${metricId}.`} />;
  }

  return (
    <div className="performance-metric-chart">
      <h3>{metricId} Data</h3>
      {/* Render chart here using 'data' */}
      <pre>{JSON.stringify(data.slice(0, 5), null, 2)}...</pre>
    </div>
  );
};

export default PerformanceMetric;

What I Expected:

I expected the PerformanceMetric component to gracefully handle unmounts during active API calls. If the component unmounts before an axios request completes, I expected the pending promise to simply resolve or reject without attempting to update state on a non-existent component, thus avoiding any warnings or potential issues.

What Actually Happened:

The application functions mostly correctly, but the console is frequently cluttered with the Warning: Can't perform a React state update on an unmounted component. message whenever users rapidly navigate between tabs or unmount the component while a fetch operation is still pending. This indicates that the setData or setError calls are being executed after the component has been removed from the DOM.

How can I reliably prevent state updates on unmounted components in my useEffect's asynchronous logic?

Advertisement

Community Insights

1
A
ADITYA KUMAR
7/19/26, 12:31 AM

Explanation of the Problem:

The warning "Can't perform a React state update on an unmounted component" is a classic race condition in React's useEffect hook, specifically when dealing with asynchronous operations like data fetching. The root cause lies in the fact that JavaScript's asynchronous operations (Promises, async/await) execute independently of the React component lifecycle.

Here's a breakdown:

  1. Component Mounts & Effect Runs: When PerformanceMetric mounts (or metricId/timeRange changes), useEffect schedules fetchMetricData.
  2. API Call Initiated: axios.get is called, sending a request to your backend. This operation is asynchronous and returns a Promise.
  3. Component Unmounts (Rapid Navigation): Before the axios Promise resolves (i.e., before the API response arrives), the user navigates away, causing PerformanceMetric to unmount.
  4. Promise Resolves After Unmount: Eventually, the axios request completes, and its Promise resolves (or rejects). The then or catch block (or try/catch in async/await) is then executed.
  5. State Update on Unmounted Component: Inside the finally block, setLoading(false) is called, and potentially setData or setError if the try block succeeds or catch fails. At this point, React has already unmounted the PerformanceMetric component, meaning its state setters (setData, setLoading, setError) are no longer associated with an active component instance. React detects this attempt to update state on a defunct component and issues the warning.

This isn't necessarily a crash, but it's a strong indicator of a potential memory leak. The browser still holds references to the component's state setters (via the closure of useEffect), and the Promise chain is still alive, even though the component itself is no longer part of the UI. If many such uncleaned-up operations accumulate, it can indeed lead to performance degradation over time.

Solution:

The recommended approach to address this is to use a cleanup function within useEffect to signal that the component is no longer mounted. This is typically done with a boolean flag, as shown below:

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Spinner, Alert } from 'some-ui-library'; // Placeholder for UI components

interface PerformanceMetricProps {
  metricId: string;
  timeRange: string; // e.g., '1h', '24h', '7d'
}

interface MetricData {
  timestamp: number;
  value: number;
}

const PerformanceMetric: React.FC<PerformanceMetricProps> = ({ metricId, timeRange }) => {
  const [data, setData] = useState<MetricData[] | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let isMounted = true; // 1. Flag to track if the component is mounted

    setLoading(true);
    setError(null);
    setData(null); // Clear previous data

    const fetchMetricData = async () => {
      try {
        const response = await axios.get<MetricData[]>(
          `/api/metrics/${metricId}?range=${timeRange}`
        );
        if (isMounted) { // 2. Only update state if the component is still mounted
          setData(response.data);
        }
      } catch (err) {
        if (isMounted) { // 3. Only update error state if mounted
          if (axios.isAxiosError(err)) {
            setError(err.message);
          } else {
            setError('An unknown error occurred.');
          }
        }
      } finally {
        if (isMounted) { // 4. Always set loading to false if mounted
          setLoading(false);
        }
      }
    };

    fetchMetricData();

    return () => {
      isMounted = false; // 5. Set the flag to false when component unmounts or dependencies change
    };
  }, [metricId, timeRange]);

  if (loading) {
    return <Spinner message={`Loading ${metricId} data...`} />;
  }

  if (error) {
    return <Alert type="error" message={`Failed to load ${metricId}: ${error}`} />;
  }

  if (!data || data.length === 0) {
    return <Alert type="info" message={`No data available for ${metricId}.`} />;
  }

  return (
    <div className="performance-metric-chart">
      <h3>{metricId} Data</h3>
      {/* Render chart here using 'data' */}
      <pre>{JSON.stringify(data.slice(0, 5), null, 2)}...</pre>
    </div>
  );
};

export default PerformanceMetric;

Explanation of Changes:

  1. let isMounted = true;: A local variable isMounted is declared and initialized to true at the beginning of the useEffect callback. This variable is part of the closure for this specific effect run.
  2. Conditional State Updates: Before calling setData, setError, or setLoading, we now check if (isMounted). This ensures that state updates only occur if the component instance associated with this effect run is still actively mounted in the DOM.
  3. return () => { isMounted = false; };: The useEffect hook allows you to return a cleanup function. This function is executed when the component unmounts, or before the effect runs again (if dependencies change). Inside this cleanup function, we set isMounted to false. This effectively "cancels" any pending state updates for that particular effect run.

Edge Cases and Related Pitfalls:

  1. Cancelling Network Requests (Beyond State Updates): While the isMounted flag prevents state updates on unmounted components, the actual network request initiated by axios still completes in the background, consuming network resources. For more robust cancellation, especially for very long-running requests or when network bandwidth is a concern, you can use AbortController. axios supports AbortController by passing a signal property to the request config. This allows you to truly cancel the network request itself.

    useEffect(() => {
      const controller = new AbortController();
      const signal = controller.signal;
      let isMounted = true;
    
      setLoading(true);
      setError(null);
      setData(null);
    
      const fetchMetricData = async () => {
        try {
          const response = await axios.get<MetricData[]>(
            `/api/metrics/${metricId}?range=${timeRange}`,
            { signal } // Pass the signal here
          );
          if (isMounted) {
            setData(response.data);
          }
        } catch (err) {
          if (axios.isAxiosError(err) && err.code === 'ERR_CANCELED') {
            console.log('Request aborted by component unmount/dependency change.');
            // This specific error means the request was intentionally cancelled.
            // We don't need to update error state for this.
          } else if (isMounted) {
            setError(err.message || 'An unknown error occurred.');
          }
        } finally {
          if (isMounted) {
            setLoading(false);
          }
        }
      };
    
      fetchMetricData();
    
      return () => {
        isMounted = false;
        controller.abort(); // Abort the actual network request when cleaning up
      };
    }, [metricId, timeRange]);
    

    This ensures that if the component unmounts, the pending axios request itself is canceled, preventing it from even reaching your backend if it hasn't left the client, or at least preventing the client from processing the response.

  2. Stale Closures (if not careful): The isMounted flag works effectively because it's captured in the closure of each useEffect run. Be cautious about trying to store this isMounted status in a useRef directly in complex ways, as that approach can sometimes lead to subtle issues if the ref isn't handled precisely with the cleanup mechanism. The let variable approach within useEffect is generally considered robust for this specific pattern.

💡 Bonus Tip:

For complex data fetching scenarios in React, especially those involving caching, re-fetching, de-duplication, and automatic background updates, consider using dedicated data fetching libraries like TanStack Query (formerly React Query) or SWR. These libraries abstract away much of the boilerplate associated with useEffect for data fetching, handle race conditions, loading states, error handling, and component unmounts automatically, significantly reducing the chances of encountering issues like the one you described. They often come with built-in AbortController integration and provide a much cleaner API for managing asynchronous data, allowing you to focus more on your UI logic.

Unlock Global Discussions

The TechLab Forum is reserved for our premium engineering community. Upgrade now to participate in high-level architectural debates.

Get Pro Access