A common memory leak issue in React occurs when event listeners aren’t properly removed when components unmount. Here’s how to handle this correctly:
The Problem
When you add event listeners but don’t remove them:
function ProblemComponent() {
useEffect(() => {
window.addEventListener('resize', handleResize);
// ❌ Missing cleanup - listener stays after unmount
}, []);
const handleResize = () => {
console.log('Window resized');
};
return <div>Resize the window</div>;
}
Consequences:
- Memory leaks (listeners keep referencing unmounted components)
- Potential errors when handlers try to update unmounted component state
- Performance degradation over time
The Solution: Cleanup Function
Class Components
class SafeComponent extends React.Component {
componentDidMount() {
window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
handleResize = () => {
console.log('Window resized');
};
render() {
return <div>Resize the window</div>;
}
}
Functional Components (with Hooks)
function SafeComponent() {
const handleResize = () => {
console.log('Window resized');
};
useEffect(() => {
window.addEventListener('resize', handleResize);
// Cleanup function
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div>Resize the window</div>;
}
Common Scenarios Requiring Cleanup
1. Custom Event Listeners
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') closeModal();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [closeModal]);
2. setTimeout/setInterval
useEffect(() => {
const timer = setTimeout(() => {
console.log('Delayed action');
}, 1000);
return () => clearTimeout(timer);
}, []);
3. WebSocket/API Subscriptions
useEffect(() => {
const socket = new WebSocket('ws://example.com');
socket.onmessage = (event) => {
console.log('Message:', event.data);
};
return () => {
socket.close();
};
}, []);
4. Third-party Library Initialization
useEffect(() => {
const map = new MapLibrary('#map-container', options);
return () => {
map.destroy();
};
}, [options]);
Best Practices
- Always return a cleanup function from useEffect
- Use the same reference for add/remove
- Handle dependencies properly:
useEffect(() => {
// Effect code
return () => {
// Cleanup code
};
}, [dependencies]); // Proper dependency array
- For anonymous functions, store in a variable:
useEffect(() => {
const handler = () => console.log('Event');
element.addEventListener('event', handler);
return () => element.removeEventListener('event', handler);
}, []);
- Use custom hooks for reusable logic:
function useEventListener(eventName, handler, element = window) {
useEffect(() => {
element.addEventListener(eventName, handler);
return () => {
element.removeEventListener(eventName, handler);
};
}, [eventName, handler, element]);
}
Debugging Tips
- Check listener count in dev tools (Chrome’s Event Listeners panel)
- Add console logs to cleanup functions
- Use React StrictMode to detect missing cleanups
- Monitor memory usage for leaks
Complete Example
function ScrollLogger() {
const [scrollPosition, setScrollPosition] = useState(0);
useEffect(() => {
const handleScroll = () => {
setScrollPosition(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
console.log('Scroll listener cleaned up');
};
}, []);
return (
<div>
<p>Current scroll position: {scrollPosition}px</p>
<div style={{ height: '200vh' }}>Scroll down</div>
</div>
);
}