Back to Blog
·4 min read

Why I stopped using React's useEffect for component cleanup

useEffect cleanup functions create memory leaks and race conditions when you don't understand React's rendering lifecycle.

AI Dev
react
hooks
performance
memory-leaks

Why I stopped using React's useEffect for component cleanup

I used to handle all my component cleanup logic in useEffect return functions. Event listeners, intervals, API request cancellations, WebSocket connections -- everything got cleaned up in that familiar cleanup pattern. It felt like the React way of doing things, following the official documentation examples and countless tutorials online. The code looked clean and followed established patterns that every React developer would recognize. Most of my cleanup functions were simple one-liners that seemed bulletproof. Then I discovered that my "perfectly clean" components were causing memory leaks in production because I fundamentally misunderstood when React actually calls those cleanup functions.

The breaking point came when debugging a dashboard that would gradually slow down and eventually crash after users navigated between different views. Memory profiling revealed that event listeners were accumulating on DOM elements, timer functions were still running after components unmounted, and API requests were completing and trying to update state on components that no longer existed. My useEffect cleanup functions looked correct -- I was properly removing event listeners, clearing intervals, and aborting fetch requests. But the timing of when these cleanups actually executed was completely different from what I expected.

The useEffect cleanup timing problem

React's useEffect cleanup functions do not run when you think they do. I assumed they executed immediately when a component unmounted, creating a clean slate for the next render. The reality is more complex:

function ProblematicComponent() {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
    
    fetch('/api/data', { signal: controller.signal })
      .then(response => response.json())
      .then(setData)
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('Fetch failed:', err);
        }
      });
 
    // This cleanup might not run when you expect
    return () => {
      controller.abort();
    };
  }, []);
 
  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}

This component looks perfectly safe, but it creates race conditions. If the component unmounts before the fetch completes, the cleanup function will abort the request -- but only after React has already scheduled the next render. In fast navigation scenarios, multiple API calls can overlap, and the cleanup happens too late to prevent state updates on unmounted components.

Memory leaks in event handlers

Event listener cleanup in useEffect creates even subtler problems:

function EventComponent() {
  useEffect(() => {
    function handleScroll() {
      // Expensive scroll handling logic
      console.log('Scrolling...');
    }
 
    window.addEventListener('scroll', handleScroll);
    
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);
 
  return <div>Some content</div>;
}

This pattern fails because React might delay cleanup function execution, especially during concurrent rendering. The scroll listener stays active longer than the component lifecycle, potentially causing performance issues or attempting to access stale closure variables.

My current approach: explicit cleanup patterns

I now handle cleanup through explicit patterns that do not rely on useEffect timing:

Use refs for cleanup tracking:

function ReliableComponent() {
  const cleanupRef = useRef<(() => void)[]>([]);
  const [data, setData] = useState(null);
 
  const addCleanup = (fn: () => void) => {
    cleanupRef.current.push(fn);
  };
 
  const cleanup = () => {
    cleanupRef.current.forEach(fn => fn());
    cleanupRef.current = [];
  };
 
  useEffect(() => {
    const controller = new AbortController();
    addCleanup(() => controller.abort());
    
    fetch('/api/data', { signal: controller.signal })
      .then(response => response.json())
      .then(result => {
        // Check if component is still mounted before updating state
        if (!controller.signal.aborted) {
          setData(result);
        }
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('Fetch failed:', err);
        }
      });
 
    return cleanup;
  }, []);
 
  // Cleanup on unmount
  useEffect(() => cleanup, []);
 
  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}

Use custom hooks for resource management:

function useEventListener(
  target: EventTarget | null,
  event: string,
  handler: EventListener
) {
  const handlerRef = useRef(handler);
  handlerRef.current = handler;
 
  useEffect(() => {
    if (!target) return;
 
    const eventListener = (e: Event) => handlerRef.current(e);
    target.addEventListener(event, eventListener);
 
    // Immediate cleanup, not deferred
    return () => target.removeEventListener(event, eventListener);
  }, [target, event]);
}
 
function ComponentWithEvents() {
  useEventListener(window, 'scroll', () => {
    console.log('Scrolling...');
  });
 
  return <div>Content</div>;
}

Key changes in my approach

  • Immediate cleanup verification: I verify that cleanup actually happened when expected, not when React decides to run it
  • Explicit resource tracking: Use refs to maintain cleanup function arrays that execute synchronously
  • Signal-based cancellation: Leverage AbortController and similar patterns for deterministic cancellation
  • Custom hooks for common patterns: Extract cleanup logic into reusable hooks that handle the complexity internally

The performance improvement was immediate. Memory usage stayed consistent during navigation, API requests cancelled reliably, and those mysterious "Cannot read property of undefined" errors disappeared. My components became more predictable because I stopped relying on React's internal scheduling for critical cleanup timing.

useEffect cleanup functions still have their place for simple scenarios, but any time you're dealing with external resources, network requests, or performance-sensitive operations, explicit cleanup patterns provide the control you actually need.