How AbortController Can Replace removeEventListener and Prevent Memory Leaks
Discover how using the AbortController API to manage event listeners can eliminate memory leaks, simplify cleanup code, and improve maintainability across vanilla JavaScript, drag‑and‑drop, and React projects, replacing the cumbersome removeEventListener pattern with a single abort call.
Stop Using removeEventListener – AbortController Saves My Job
Yesterday a product manager complained that our admin panel consumed 2 GB of RAM, caused by forgotten event listeners. The author realized that using AbortController can centralize listener registration and cleanup.
Original Messy Implementation
export default class DataGrid {
constructor(container, options) {
this.container = container;
this.options = options;
this.handleResize = this.handleResize.bind(this);
this.handleScroll = this.handleScroll.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
this.handleContextMenu = this.handleContextMenu.bind(this);
this.init();
}
init() {
// register listeners
window.addEventListener('resize', this.handleResize);
this.container.addEventListener('scroll', this.handleScroll);
this.container.addEventListener('click', this.handleClick);
document.addEventListener('keydown', this.handleKeydown);
this.container.addEventListener('contextmenu', this.handleContextMenu);
// timers
this.resizeTimer = null;
this.scrollTimer = null;
}
destroy() {
// often forget to remove some listeners
window.removeEventListener('resize', this.handleResize);
this.container.removeEventListener('scroll', this.handleScroll);
this.container.removeEventListener('click', this.handleClick);
document.removeEventListener('keydown', this.handleKeydown);
// contextmenu removal missed
if (this.resizeTimer) clearTimeout(this.resizeTimer);
if (this.scrollTimer) clearTimeout(this.scrollTimer);
}
}The code suffers from repetitive binding, easy omission of cleanup, and hard maintenance.
Using AbortController for Unified Cleanup
export default class DataGrid {
constructor(container, options) {
this.container = container;
this.options = options;
this.controller = new AbortController();
this.init();
}
init() {
const { signal } = this.controller;
window.addEventListener('resize', e => {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => this.handleResize(e), 200);
}, { signal });
this.container.addEventListener('scroll', e => this.handleScroll(e), { signal, passive: true });
this.container.addEventListener('click', e => this.handleClick(e), { signal });
document.addEventListener('keydown', e => {
if (e.key === 'Delete' && this.selectedRows.length > 0) {
this.deleteSelectedRows();
}
}, { signal });
this.container.addEventListener('contextmenu', e => {
e.preventDefault();
this.showContextMenu(e);
}, { signal });
}
destroy() {
// one line cleans everything
this.controller.abort();
}
}Calling controller.abort() removes all listeners registered with the same signal.
Common Pitfalls
AbortController cannot be reused after abort; a new instance must be created for subsequent uses. The article shows a wrong modal example and the corrected version.
Drag‑and‑Drop Sorting Example
class DragSort {
constructor(container) {
this.container = container;
this.isDragging = false;
this.dragElement = null;
this.initDrag();
}
initDrag() {
const dragController = new AbortController();
this.dragController = dragController;
const { signal } = dragController;
this.container.addEventListener('mousedown', e => {
const card = e.target.closest('.card');
if (!card) return;
this.startDrag(card, e);
}, { signal });
}
startDrag(card, startEvent) {
const moveController = new AbortController();
const { signal } = moveController;
this.isDragging = true;
this.dragElement = card;
const startX = startEvent.clientX;
const startY = startEvent.clientY;
const rect = card.getBoundingClientRect();
const ghost = card.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.left = rect.left + 'px';
ghost.style.top = rect.top + 'px';
ghost.style.pointerEvents = 'none';
ghost.style.opacity = '0.8';
document.body.appendChild(ghost);
document.addEventListener('mousemove', e => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
ghost.style.left = rect.left + deltaX + 'px';
ghost.style.top = rect.top + deltaY + 'px';
this.updateDropIndicator(e);
}, { signal });
document.addEventListener('mouseup', () => {
this.endDrag(ghost);
moveController.abort();
}, { signal, once: true });
document.addEventListener('selectstart', e => e.preventDefault(), { signal });
document.addEventListener('contextmenu', e => e.preventDefault(), { signal });
}
destroy() {
this.dragController?.abort();
}
}Each drag creates its own controller, ensuring automatic cleanup.
React Hook Wrapper
import { useEffect, useRef } from 'react';
function useEventController() {
const controllerRef = useRef();
useEffect(() => {
controllerRef.current = new AbortController();
return () => {
controllerRef.current?.abort();
};
}, []);
const addEventListener = (target, event, handler, options = {}) => {
if (!controllerRef.current) return;
const element = target?.current || target;
if (!element) return;
element.addEventListener(event, handler, {
signal: controllerRef.current.signal,
...options,
});
};
return { addEventListener };
}
// usage
function MyComponent() {
const { addEventListener } = useEventController();
const buttonRef = useRef();
useEffect(() => {
addEventListener(window, 'resize', () => console.log('window resized'));
addEventListener(buttonRef, 'click', () => console.log('button clicked'));
}, []);
return <button ref={buttonRef}>Click me</button>;
}The hook creates a single AbortController for the component and aborts it on unmount.
Browser Compatibility
AbortController is supported in Chrome 66+, Firefox 57+, Safari 11.1+. For older browsers you can fall back to manual listener tracking.
class EventManager {
constructor() {
this.useAbortController = 'AbortController' in window;
if (this.useAbortController) {
this.controller = new AbortController();
} else {
this.handlers = [];
}
}
on(target, event, handler, options = {}) {
if (this.useAbortController) {
target.addEventListener(event, handler, { signal: this.controller.signal, ...options });
} else {
this.handlers.push({ target, event, handler, options });
target.addEventListener(event, handler, options);
}
}
destroy() {
if (this.useAbortController) {
this.controller.abort();
} else {
this.handlers.forEach(({ target, event, handler, options }) => {
target.removeEventListener(event, handler, options);
});
this.handlers = [];
}
}
}In the author’s project the modern browsers made the AbortController approach safe to adopt.
Conclusion
AbortController not only cancels fetch requests; it can also manage any event listener, turning a scattered removeEventListener cleanup into a single abort() call, reducing bugs and maintenance effort.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
