no-unmanaged-event-listeners
Require event listeners to have an abort signal or a matching cleanup call.
Rule catalog ID: R002
Targeted pattern scopeโ
This rule targets EventTarget#addEventListener(...) calls where the listener
registration has no visible cleanup path.
The rule accepts two cleanup models:
- passing an options object with a
signalproperty, so anAbortControllerowns listener teardown; - or calling
removeEventListener(...)with the same target, event type, listener, and capture mode in the same function or program boundary.
What this rule reportsโ
The rule reports addEventListener(...) calls when both of these are true:
- the third argument does not resolve to an object with a
signalproperty; - and no matching
removeEventListener(...)call exists in the same lexical boundary.
The same-boundary requirement is intentional. Cleanup in a different function is often real, but matching cross-function ownership without framework knowledge is too noisy for a syntax-only rule.
Capture matchingโ
removeEventListener(...) only removes the original listener when the capture
mode matches. This rule tracks the common capture forms:
- no options argument, which is treated as
false; - boolean options such as
true; - object options such as
{ capture: true }.
Opaque option values are only considered a match when the same option expression is passed to both calls.
Why this rule existsโ
Listeners keep callbacks and their captured state alive. In browser code, that can retain component state after unmount. In long-lived services, repeated registration without teardown creates duplicate work and memory leaks.
An abort signal is usually the clearest ownership model for modern event
targets. A matching removeEventListener(...) call is still valid when the
lifecycle boundary is small and obvious.
Incorrectโ
button.addEventListener("click", handleClick);
window.addEventListener("resize", () => layout());
target.addEventListener("scroll", onScroll, true);
target.removeEventListener("scroll", onScroll, false);
function setup() {
window.addEventListener("resize", onResize);
}
function cleanup() {
window.removeEventListener("resize", onResize);
}
Correctโ
const controller = new AbortController();
button.addEventListener("click", handleClick, {
signal: controller.signal,
});
controller.abort();
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);
target.addEventListener("scroll", onScroll, { capture: true });
target.removeEventListener("scroll", onScroll, { capture: true });
function setup() {
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}
Behavior and migration notesโ
Prefer an AbortController when listener ownership belongs to a component,
request, disposable object, or other lifecycle boundary that may later own
multiple resources.
Use removeEventListener(...) when the add/remove pair is intentionally local
and the same callback reference is available. Inline callbacks without a signal
are reported because there is no stable callback reference to remove later.
This rule does not autofix because choosing the right cleanup lifetime is a semantic decision.
ESLint flat config exampleโ
import runtimeCleanup from "eslint-plugin-runtime-cleanup";
export default [
runtimeCleanup.configs.recommended,
];
When not to use itโ
Do not enable this rule for code that intentionally registers process-lifetime or page-lifetime listeners. Prefer a narrow disable comment with a reason when the listener is meant to live for the whole runtime.