Not canceling API requests on unmount leading to memory leaks

Loading

Preventing Memory Leaks from Uncanceled API Requests

A critical performance issue in React applications occurs when API requests continue running after components unmount, potentially causing memory leaks and state updates on unmounted components.

The Problem: Orphaned API Requests

// ❌ Dangerous - no request cancellation
useEffect(() => {
  const fetchData = async () => {
    const response = await fetch('/api/data');
    setData(await response.json());
  };

  fetchData();
}, []);

Why This is Dangerous

  1. Memory Leaks: Component is gone but request continues
  2. State Updates on Unmounted Components: Causes React warnings
  3. Wasted Resources: Network bandwidth and processing
  4. Race Conditions: Old requests may override newer ones
  5. Potential Errors: Attempting to update nonexistent state

Correct Implementation Patterns

1. Using AbortController (Modern Fetch API)

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

  const fetchData = async () => {
    try {
      const response = await fetch('/api/data', {
        signal: controller.signal
      });
      const data = await response.json();
      setData(data);
    } catch (error) {
      if (error.name !== 'AbortError') {
        setError(error.message);
      }
    }
  };

  fetchData();

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

2. Using Axios CancelToken

useEffect(() => {
  const source = axios.CancelToken.source();

  const fetchData = async () => {
    try {
      const response = await axios.get('/api/data', {
        cancelToken: source.token
      });
      setData(response.data);
    } catch (error) {
      if (!axios.isCancel(error)) {
        setError(error.message);
      }
    }
  };

  fetchData();

  return () => source.cancel('Component unmounted');
}, []);

3. Mounted Check Pattern (Fallback)

useEffect(() => {
  let isMounted = true;

  const fetchData = async () => {
    try {
      const response = await fetch('/api/data');
      const data = await response.json();
      if (isMounted) setData(data); // Only update if mounted
    } catch (error) {
      if (isMounted) setError(error.message);
    }
  };

  fetchData();

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

Best Practices

  1. Always Clean Up: Every useEffect with async operations needs cleanup
  2. Prefer AbortController: Native browser API for fetch cancellation
  3. Handle Cancel Errors: Specifically catch and ignore abort errors
  4. Use Loading States: Prevent UI updates during unmount
  5. Consider Libraries: React Query/SWR handle cancellation automatically

Common Pitfalls

  1. Forgetting to Cancel:
   useEffect(() => {
     fetch('/api/data').then(setData); // ❌ No cleanup
   }, []);
  1. Canceling Too Early:
   useEffect(() => {
     const controller = new AbortController();
     fetch('/api/data', { signal: controller.signal });
     controller.abort(); // ❌ Cancels immediately
   }, []);
  1. Ignoring AbortError:
   try {
     // fetch...
   } catch (error) {
     setError(error); // ❌ Includes AbortError
   }

Advanced Patterns

1. Custom Hook with Cancellation

function useCancellableFetch() {
  const controllerRef = useRef(new AbortController());

  const fetchData = async (url, options = {}) => {
    controllerRef.current.abort(); // Cancel previous
    controllerRef.current = new AbortController();

    try {
      const response = await fetch(url, {
        ...options,
        signal: controllerRef.current.signal
      });
      return await response.json();
    } catch (error) {
      if (error.name !== 'AbortError') throw error;
    }
  };

  useEffect(() => {
    return () => controllerRef.current.abort();
  }, []);

  return fetchData;
}

// Usage
function MyComponent() {
  const fetchData = useCancellableFetch();

  useEffect(() => {
    fetchData('/api/data').then(setData).catch(setError);
  }, [fetchData]);
}

2. Combining with Debouncing

useEffect(() => {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => {
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setResults);
  }, 300);

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

3. With React Query (Recommended Solution)

import { useQuery } from 'react-query';

function MyComponent() {
  const { data } = useQuery('data', () => 
    fetch('/api/data').then(res => res.json())
  );
  // Automatic cleanup and cancellation
}

Key Takeaways

  1. AbortController is Your Friend: The modern way to cancel fetch requests
  2. Cleanup Functions are Mandatory: For any effect with async operations
  3. Race Conditions Matter: New requests should cancel old ones
  4. Error Handling is Crucial: Specifically handle cancellation errors
  5. Consider Higher-Level Solutions: Libraries like React Query simplify this

Remember: Uncanceled API requests are among the most common sources of memory leaks in React applications. Always implement proper cleanup to ensure your application remains performant and error-free.

Leave a Reply

Your email address will not be published. Required fields are marked *