Skip to Content
Fix a Bound Event Listener That Will Not Detach
Advanced
0 likes
Tutorials

Fix a Bound Event Listener That Will Not Detach

Trace a wrong callback receiver, bind one stable listener, and verify start, stop and restart without duplicate or stale subscriptions.

An event listener can need two repairs: it must run with the intended instance as this, and cleanup must remove the exact callback that was registered. Binding a new function during both registration and removal solves only the first problem.

This guide follows a click counter through start, stop and restart. The course lesson on call-site this compares the general binding approaches. Here the pass condition is a subscription that fires once while active and never after it is stopped.

Reproduce the wrong receiver

Save this complete page as click-meter.html. Open it in a current browser with DevTools Console visible. Click Start listening, then Count a click.

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Click listener lifecycle</title>
<style>
  body { font: 1rem/1.5 system-ui; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
  button { font: inherit; padding: .5rem; margin: .25rem; }
  output { display: block; margin-top: 1rem; }
</style>
<h1>Click meter</h1>
<button id="start" type="button">Start listening</button>
<button id="stop" type="button">Stop listening</button>
<button id="tick" type="button">Count a click</button>
<p id="subscription">Subscription: stopped</p>
<output id="count" aria-live="polite">Count: 0</output>
<script>
class ClickMeter {
  constructor(button, output, status) {
    this.button = button;
    this.output = output;
    this.status = status;
    this.count = 0;
    this.active = false;
  }
  start() {
    if (this.active) return;
    this.button.addEventListener("click", this.onClick, false);
    this.active = true;
    this.status.textContent = "Subscription: listening";
  }
  stop() {
    this.button.removeEventListener("click", this.onClick, false);
    this.active = false;
    this.status.textContent = "Subscription: stopped";
  }
  onClick() {
    this.count += 1;
    this.output.textContent = `Count: ${this.count}`;
  }
}

const meter = new ClickMeter(
  document.querySelector("#tick"),
  document.querySelector("#count"),
  document.querySelector("#subscription")
);
document.querySelector("#start").addEventListener("click", () => meter.start());
document.querySelector("#stop").addEventListener("click", () => meter.stop());
</script>
</html>

The counter should fail to update and the Console should show a TypeError when the method tries to access its output. For a regular function listener, the browser calls it with the event target that owns the listener as this; here that is the Count a click button, not the ClickMeter instance. If inspected during the handler, this matches event.currentTarget. This DOM case is different from a plain unbound class-method call where this can be undefined.

Avoid creating a different callback during removal

These two lines look symmetrical, but they create two separate bound functions. They are a deliberate failed repair; do not keep them in the final page:

this.button.addEventListener("click", this.onClick.bind(this), false);
this.button.removeEventListener("click", this.onClick.bind(this), false);

With those replacements, Start and Count appear to work. After Stop, the counter still increments because removal did not match the registered function. Start again and the old listener can run alongside the new one. A label saying “stopped” is not evidence that the subscription ended.

Bind once and keep that identity

Reload a clean copy of the original page, then replace only its constructor with the one below. Keep start and stop using this.onClick directly, as in the original complete example.

constructor(button, output, status) {
  this.button = button;
  this.output = output;
  this.status = status;
  this.count = 0;
  this.active = false;
  this.onClick = this.onClick.bind(this);
}

The constructor stores one bound callback on that instance. Both add and remove now receive that same function, and it always calls the method with the meter instance. A new ClickMeter gets its own callback; create one instance for the lifecycle you are managing.

The active guard makes repeated Start calls harmless. Stop removes the listener and sets the lifecycle state back to stopped. The handler deliberately does not hide a failed removal behind an early active-state check, so the test can reveal a stale subscription.

Verify the complete lifecycle

Reload to discard listeners created by the broken experiment. Use this sequence without reloading between steps:

ActionExpected count
Count before Start0
Start twice, then Count1
Stop, then CountStill 1
Start, then Count2
Stop twice, then CountStill 2

Repeat several start/count/stop/count cycles. Each active click should add exactly one; each stopped click should add nothing. Keep the Count button enabled after Stop so the test actually exercises removal. Keyboard activation should follow the same lifecycle as pointer clicks.

Match the full subscription, not just the method name

Removal must match the target, event type, callback identity and capture flag. Both calls here use the same button, click, stored function and false. Replacing the button node or changing the capture flag creates a different subscription to manage.

In a component, call cleanup before discarding the owning instance. Re-creating a function with identical source code does not recreate its identity. Keep the subscription data where the component can reach it during disposal.

Diagnose a result that still grows after Stop

  • Every restart adds another increment: look for a fresh bind or arrow during registration and a different function during cleanup.
  • Binding works but removal does not: check that the callback was stored once, and compare target, type and capture.
  • The original TypeError remains: remove the old experiment with a reload, then confirm the constructor actually stores the bound callback.
  • The view looks stopped: click the source button anyway. An inactive label does not prove that its listener was detached.

Try a capture mismatch

In a fresh repaired page, change registration to use true but leave removal at false. Start, Count, Stop, Count. Should the second Count change the output?

Answer: yes. The capture-phase registration was not matched by the removal. Use the same capture flag on both calls, reload to clear the experiment, and repeat. Fixing this and preserving a callback reference are necessary, but the rest of the subscription must match too.

References: listener this and currentTarget and listener-removal matching rules.

About

Trace a wrong callback receiver, bind one stable listener, and verify start, stop and restart without duplicate or stale subscriptions.

Responsible
Jay P
Last Update
09/09/2026
Reading Time
6 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.