Skip to main content

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 signal property, so an AbortController owns 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 signal property;
  • 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.

Further readingโ€‹