Using useEffect without handling async cleanup properly

Loading

Proper Async Cleanup in React’s useEffect

A critical but often overlooked aspect of React development is properly handling cleanup for asynchronous operations in useEffect. Failing to do this can lead to memory leaks, state updates on unmounted components, and other subtle bugs.

The Core Problem

Here’s the problematic pattern:

useEffect(() => {
  const fetchData = async () => {
    const data = await fetch('/api/data');
    setData(data); // ❌ Potential state update after unmount
  };
  fetchData();
}, []);

Issues this causes:

  • Memory leaks from ongoing async operations
  • Can’t cancel in-progress requests
  • Attempts to update state after component unmounts
  • Potential race conditions

Correct Implementation

1. Basic Cleanup with Flag

useEffect(() => {
  let isMounted = true; // ✅ Track mounted state

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

  fetchData();

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

2. Advanced Cleanup with AbortController

useEffect(() => {
  const controller = new AbortController(); // ✅ For fetch cancellation

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

  fetchData();

  return () => {
    controller.abort(); // ✅ Cancel fetch on unmount
  };
}, []);

Common Scenarios Requiring Cleanup

1. API Requests

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

  getUser(id).then(user => {
    if (isMounted) setUser(user);
  });

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

2. WebSocket Connections

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

  socket.onmessage = (event) => {
    setMessages(prev => [...prev, event.data]);
  };

  return () => {
    socket.close(); // ✅ Cleanup WebSocket
  };
}, []);

3. Timers and Intervals

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

  return () => {
    clearInterval(timer); // ✅ Cleanup interval
  };
}, []);

Advanced Patterns

1. Combining AbortController with Mount Check

useEffect(() => {
  const controller = new AbortController();
  let isMounted = true;

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

  fetchData();

  return () => {
    isMounted = false;
    controller.abort();
  };
}, [id]); // Re-runs when id changes

2. Custom Hook with Cleanup

function useAsyncEffect(effect, deps) {
  useEffect(() => {
    let isMounted = true;
    let cleanupFn = null;

    const execute = async () => {
      cleanupFn = await effect(isMounted);
    };

    execute();

    return () => {
      isMounted = false;
      if (cleanupFn) cleanupFn();
    };
  }, deps);
}

// Usage
useAsyncEffect(async (isMounted) => {
  const data = await fetchData();
  if (isMounted) setData(data);

  return () => {
    // Optional cleanup logic
  };
}, []);

Common Mistakes

❌ Ignoring Promise Rejections

useEffect(() => {
  fetchData(); // ❌ Unhandled promise rejection
}, []);

Fix: Add error handling

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

  fetchData()
    .then(data => {
      if (isMounted) setData(data);
    })
    .catch(err => {
      if (isMounted) setError(err);
    });

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

❌ Not Cleaning Up Pending Requests

useEffect(() => {
  const fetchData = async () => {
    const data = await fetchUser(id); // ❌ Can't cancel
    setUser(data);
  };
  fetchData();
}, [id]); // Changing id creates race conditions

Fix: Use AbortController

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

  const fetchData = async () => {
    try {
      const data = await fetchUser(id, { signal: controller.signal });
      setUser(data);
    } catch (err) {
      if (err.name !== 'AbortError') setError(err);
    }
  };

  fetchData();

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

Best Practices

  1. Always return a cleanup function from useEffect
  2. Cancel async operations when possible (fetch, axios, etc.)
  3. Check mounted state before updating state
  4. Handle errors properly including abort errors
  5. Use AbortController for modern fetch cancellation
  6. Consider custom hooks for complex async logic

Key Takeaways

  1. Async operations need cleanup to prevent memory leaks
  2. AbortController is your friend for canceling fetch requests
  3. Mounted checks prevent state updates on unmounted components
  4. Error handling is essential for robust components
  5. Cleanup functions should match your effect’s operations

Proper async cleanup makes your React components more robust and prevents subtle bugs that can be difficult to track down in production applications. Always consider what needs to be cleaned up when your effect runs or when the component unmounts.

Leave a Reply

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