Not handling cleanup inside useEffect when dealing with subscriptions

Loading

Proper Cleanup in useEffect for Subscriptions

A critical React best practice is properly cleaning up subscriptions, event listeners, and asynchronous operations in useEffect to prevent memory leaks and unexpected behavior.

The Problem

function ChatComponent() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    // ❌ No cleanup - memory leak!
    socket.on('newMessage', (msg) => {
      setMessages(prev => [...prev, msg]);
    });
  }, []);

  return <MessageList messages={messages} />;
}

Why this is dangerous:

  1. Subscription remains active after component unmounts
  2. Can cause memory leaks
  3. May try to update state of unmounted component
  4. Can lead to inconsistent application state

Correct Solutions

1. Basic Cleanup Function

useEffect(() => {
  const handleMessage = (msg) => setMessages(prev => [...prev, msg]);

  socket.on('newMessage', handleMessage);

  // ✅ Cleanup function
  return () => {
    socket.off('newMessage', handleMessage);
  };
}, []);

2. For Timers/Intervals

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Tick');
  }, 1000);

  // ✅ Clear interval on unmount
  return () => clearInterval(timer);
}, []);

3. For Async Operations

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

  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      if (isMounted) { // ✅ Check mounted state
        setData(data);
      }
    });

  return () => {
    isMounted = false; // ✅ Cancel pending update
  };
}, []);

4. For Event Listeners

useEffect(() => {
  const handleResize = () => setWidth(window.innerWidth);

  window.addEventListener('resize', handleResize);

  // ✅ Remove listener
  return () => window.removeEventListener('resize', handleResize);
}, []);

Common Subscription Types Needing Cleanup

  1. WebSocket connections
  2. API subscriptions (GraphQL, Firebase)
  3. DOM event listeners
  4. Timers (setTimeout, setInterval)
  5. Observables (RxJS)
  6. Browser APIs (geolocation, media queries)

Advanced Patterns

1. AbortController for Fetch

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

  fetch('/api', { signal: controller.signal })
    .then(/* ... */)
    .catch(e => {
      if (e.name !== 'AbortError') {
        console.error(e);
      }
    });

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

2. Custom Hook for Subscriptions

function useSocket(event, callback) {
  useEffect(() => {
    socket.on(event, callback);
    return () => socket.off(event, callback);
  }, [event, callback]);
}

// Usage
function Chat() {
  useSocket('newMessage', (msg) => {
    setMessages(prev => [...prev, msg]);
  });
}

3. Combining Multiple Cleanups

useEffect(() => {
  const timer = setInterval(/* ... */);
  const resizeListener = () => {/* ... */};
  window.addEventListener('resize', resizeListener);

  // ✅ Single cleanup function
  return () => {
    clearInterval(timer);
    window.removeEventListener('resize', resizeListener);
  };
}, []);

Common Mistakes to Avoid

  1. Forgetting cleanup entirely:
   useEffect(() => {
     PubSub.subscribe('update', callback); // ❌ No cleanup
   }, []);
  1. Incorrect cleanup dependencies:
   useEffect(() => {
     const id = setInterval(() => {}, delay);
     return () => clearInterval(id);
   }, []); // ❌ Missing delay dependency
  1. Async cleanup functions (not supported):
   return async () => { // ❌ Won't work
     await unsubscribe();
   };
  1. Conditional cleanup:
   return () => {
     if (condition) { // ❌ Avoid if possible
       unsubscribe();
     }
   };

Best Practices

  1. Always return a cleanup function when setting up subscriptions
  2. Match 1:1 setup/cleanup – Every setup should have corresponding cleanup
  3. Test unmount behavior – Verify cleanup works
  4. Use linter rules – ESLint will warn about missing cleanups
  5. Consider custom hooks for reusable subscription logic

Real-World Example

function LiveDataWidget({ dataId }) {
  const [data, setData] = useState(null);

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

    // Fetch initial data
    fetch(`/api/data/${dataId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => isMounted && setData(data));

    // Subscribe to updates
    const unsubscribe = dataService.subscribe(dataId, (update) => {
      if (isMounted) setData(update);
    });

    // Comprehensive cleanup
    return () => {
      isMounted = false;
      controller.abort();
      unsubscribe();
    };
  }, [dataId]); // Re-run when dataId changes

  return data ? <DataView data={data} /> : <LoadingSpinner />;
}

Leave a Reply

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