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:
- Subscription remains active after component unmounts
- Can cause memory leaks
- May try to update state of unmounted component
- 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
- WebSocket connections
- API subscriptions (GraphQL, Firebase)
- DOM event listeners
- Timers (setTimeout, setInterval)
- Observables (RxJS)
- 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
- Forgetting cleanup entirely:
useEffect(() => {
PubSub.subscribe('update', callback); // ❌ No cleanup
}, []);
- Incorrect cleanup dependencies:
useEffect(() => {
const id = setInterval(() => {}, delay);
return () => clearInterval(id);
}, []); // ❌ Missing delay dependency
- Async cleanup functions (not supported):
return async () => { // ❌ Won't work
await unsubscribe();
};
- Conditional cleanup:
return () => {
if (condition) { // ❌ Avoid if possible
unsubscribe();
}
};
Best Practices
- Always return a cleanup function when setting up subscriptions
- Match 1:1 setup/cleanup – Every setup should have corresponding cleanup
- Test unmount behavior – Verify cleanup works
- Use linter rules – ESLint will warn about missing cleanups
- 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 />;
}