Handling Window Events Properly in React
A common source of memory leaks in React applications comes from adding window event listeners without properly removing them when components unmount. Here’s how to handle window events correctly:
The Problem: Orphaned Event Listeners
function MyComponent() {
const handleScroll = () => {
console.log(window.scrollY);
};
useEffect(() => {
// ❌ Missing cleanup - listener remains after component unmounts
window.addEventListener('scroll', handleScroll);
}, []);
return <div>Scroll position tracker</div>;
}
Why This is Dangerous
- Memory Leaks: The component stays in memory because the window maintains a reference
- Performance Issues: Multiple listeners accumulate if component remounts
- Zombie Functions: The handler might try to update unmounted component state
- Stale Closures: Handlers may reference outdated state/props values
Correct Solutions
1. Basic Cleanup in useEffect
useEffect(() => {
const handleScroll = () => {
console.log(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
// ✅ Proper cleanup
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
2. With Dependencies
function MyComponent({ threshold }) {
useEffect(() => {
const handleScroll = () => {
if (window.scrollY > threshold) {
console.log('Threshold passed');
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [threshold]); // Re-subscribes when threshold changes
}
3. Debounced Events
useEffect(() => {
const handleResize = debounce(() => {
console.log(window.innerWidth);
}, 200);
window.addEventListener('resize', handleResize);
return () => {
handleResize.cancel(); // Cancel any pending executions
window.removeEventListener('resize', handleResize);
};
}, []);
Class Component Implementation
class WindowSizeTracker extends React.Component {
state = { width: window.innerWidth };
handleResize = () => {
this.setState({ width: window.innerWidth });
};
componentDidMount() {
window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
render() {
return <div>Window width: {this.state.width}px</div>;
}
}
Common Window Events to Clean Up
- Scroll events (
scroll
) - Resize events (
resize
) - Keyboard events (
keydown
,keyup
) - Mouse events (
mousemove
,mouseup
) - Storage events (
storage
) - Beforeunload events (
beforeunload
)
Advanced Patterns
Custom Hook for Window Events
function useWindowEvent(event, callback, dependencies = []) {
useEffect(() => {
window.addEventListener(event, callback);
return () => window.removeEventListener(event, callback);
}, [event, callback, ...dependencies]);
}
// Usage
function MyComponent() {
useWindowEvent('mousemove', (e) => {
console.log(e.clientX, e.clientY);
});
return <div>Mouse tracker</div>;
}
Throttled Event Listener
useEffect(() => {
const handleScroll = throttle(() => {
console.log('Scrolling...');
}, 100);
window.addEventListener('scroll', handleScroll);
return () => {
handleScroll.cancel();
window.removeEventListener('scroll', handleScroll);
};
}, []);
Best Practices
- Always remove event listeners in the cleanup function
- Use stable references for event handlers (useCallback if needed)
- Include all dependencies that the handler uses
- Consider debouncing/throttling for frequent events
- Test for memory leaks using browser dev tools
Debugging Tips
If you suspect listener leaks:
- Check
getEventListeners(window)
in Chrome DevTools - Monitor memory usage when mounting/unmounting components
- Look for duplicate event handling
- Verify cleanup functions are being called
Remember: Any event listener added to global objects like window
or document
must be cleaned up when your component unmounts to prevent memory leaks and unexpected behavior.