Properly Managing Event Listeners in React Components
A critical but often overlooked aspect of React development is cleaning up event listeners when components unmount. Failing to do so can lead to memory leaks and unexpected behavior.
The Problem: Orphaned Event Listeners
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// ❌ Missing cleanup - listener remains after unmount
window.addEventListener('resize', handleResize);
}, []);
const handleResize = () => {
console.log('Window resized');
};
return <div>My Component</div>;
}
Why This is Dangerous
- Memory Leaks: Event handlers maintain references to components
- Zombie Handlers: Attempts to update unmounted component state
- Performance Issues: Multiple redundant listeners accumulate
- Stale Closures: Handlers may reference outdated state/props
Correct Solutions
1. Basic Cleanup in useEffect
useEffect(() => {
window.addEventListener('resize', handleResize);
// ✅ Proper cleanup
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
2. For Custom Events
useEffect(() => {
const eventSource = new EventSource('/stream');
eventSource.addEventListener('message', handleMessage);
return () => {
eventSource.removeEventListener('message', handleMessage);
eventSource.close();
};
}, []);
3. With Dependencies
useEffect(() => {
const handler = () => {
console.log('Current value:', someProp);
};
document.addEventListener('click', handler);
return () => {
document.removeEventListener('click', handler);
};
}, [someProp]); // Re-subscribes when someProp changes
Class Component Example
class MyComponent extends React.Component {
handleResize = () => {
console.log('Window resized');
};
componentDidMount() {
window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
// ✅ Cleanup in class component
window.removeEventListener('resize', this.handleResize);
}
render() {
return <div>My Component</div>;
}
}
Common Pitfalls
- Anonymous Functions:
useEffect(() => {
const handler = () => console.log('click');
document.addEventListener('click', handler);
return () => {
// ❌ Won't work - different function instance
document.removeEventListener('click', () => console.log('click'));
};
}, []);
- Missing Dependencies:
useEffect(() => {
const handler = () => console.log(someState);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
// ❌ Missing someState dependency
}, []);
- Multiple Listeners:
useEffect(() => {
// ❌ Adds new listener on every render
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}); // ❌ Missing dependency array
Best Practices
- Always return a cleanup function from useEffect
- Use named handlers for consistent reference
- Include all dependencies in the dependency array
- Consider custom hooks for reusable listener logic
- Test for memory leaks using dev tools
Advanced Patterns
Custom Hook Example
function useWindowEvent(event, handler, dependencies = []) {
useEffect(() => {
window.addEventListener(event, handler);
return () => window.removeEventListener(event, handler);
}, [event, handler, ...dependencies]);
}
// Usage
function MyComponent() {
useWindowEvent('resize', () => {
console.log('Window resized');
});
return <div>My Component</div>;
}
AbortController for Fetch Requests
useEffect(() => {
const controller = new AbortController();
fetch('/api', { signal: controller.signal })
.then(response => response.json())
.then(data => setData(data));
return () => {
controller.abort(); // Cancels the fetch on unmount
};
}, []);
Remember: Proper cleanup is essential for maintaining application performance and preventing subtle bugs. Always consider what needs to be cleaned up when your component unmounts.