Give each mounted JavaScript view a fresh AbortController, register its listeners with that controller’s signal, and abort it during unmount. This tutorial builds a small browser view and shows how to check repeated mount cycles in Chrome on a Mac.

Why do event handlers accumulate when a view reopens?

A view can appear correct on its first opening and start responding twice after reopening. A common cause is setup code that creates another callback on every mount without removing the previous registration. Functions created separately are different listener objects, even when their source text is identical. MDN’s addEventListener() documentation explains this callback identity rule.

The DOM prevents duplicate registrations with the same event type, callback and capture value on the same target. That protection does not solve repeated mounting when each setup creates a new callback. The standard also specifies that aborting a listener’s supplied signal removes that listener. These rules support a practical design: one controller owns all native event listeners for one mounted interval. DOM Standard: EventTarget.

For this tutorial, the reader’s task is to open, close and reopen a plain JavaScript counter view without adding extra button or window handlers. The example uses synthetic labels, requires no framework and keeps its counters between mounts.

What lifecycle contract should the view follow?

Decide what repeated lifecycle calls mean before implementing setup. The following contract makes accidental repeated calls harmless and gives cleanup a clear owner.

Operation Required behavior
First mount() Attach the view and register one set of listeners.
Another mount() while mounted Return without changing anything.
unmount() Remove the mounted interval’s listeners, then detach the view.
Another unmount() Return without changing anything.
mount() after unmount Register a new set using a fresh controller; preserve counters.
destroy() Unmount permanently; later mounting throws an error.

These are application design choices. The browser provides event registration and removal; your view supplies the lifecycle methods and their ordering. In a larger application, the component or router that calls mount() should also arrange the matching unmount().

A fresh controller matters because an aborted signal cannot become active again. When addEventListener() receives an already aborted signal, the DOM registration algorithm returns without adding the listener. Reusing the old controller can therefore produce a reopened view whose controls do nothing. DOM Standard: adding an event listener.

Build a complete example in Chrome on your Mac

Open a new Chrome tab, enter about:blank in the address bar and press Return. With the page focused, press Command + Option + J to open the Console. Google documents this Mac shortcut in its Chrome DevTools keyboard reference.

Run the complete program below in the Console. It creates the interface through JavaScript, so there is no document markup, package installation or build step. Chrome’s console can evaluate JavaScript and change the current page’s DOM, as demonstrated in Google’s Run JavaScript in the Console tutorial.

(() => {
  // Clean up the previous demo if this entire program is run again.
  window.lifecycleDemo?.destroy();

  const host = document.body;
  const root = document.createElement("section");
  const button = document.createElement("button");
  const output = document.createElement("p");

  button.type = "button";
  button.textContent = "Add one";
  root.append(button, output);

  let controller = null;
  let destroyed = false;
  let clicks = 0;
  let resizes = 0;

  function render() {
    output.textContent =
      `Clicks: ${clicks}; resize events: ${resizes}`;
  }

  function mount() {
    if (destroyed) {
      throw new Error("This view has been destroyed.");
    }
    if (controller !== null) return;

    const next = new AbortController();
    const options = { signal: next.signal };

    try {
      button.addEventListener("click", () => {
        clicks += 1;
        render();
      }, options);

      window.addEventListener("resize", () => {
        resizes += 1;
        render();
      }, options);

      render();
      host.append(root);
      controller = next;
    } catch (error) {
      // Roll back any registrations made before setup failed.
      next.abort();
      root.remove();
      throw error;
    }
  }

  function unmount() {
    if (controller === null) return;
    controller.abort();
    controller = null;
    root.remove();
  }

  function destroy() {
    unmount();
    destroyed = true;
  }

  window.lifecycleDemo = {
    mount,
    unmount,
    destroy,
    button,
    get counts() {
      return { clicks, resizes };
    }
  };

  mount();
})();

The page now contains an Add one button and a counter line. Click the button: the click count should increase by one. Resize the Chrome window: the resize-event count should change. A physical resize can generate several events, so that count is an event count rather than a count of mouse gestures.

The button listener and the window listener share the same signal. Unmount aborts that controller before detaching the root. The next mount creates another controller and another set of callbacks. The counters remain in the view’s closure, so reopening preserves their values; destroying and rerunning the whole program starts a new instance.

The setup block also handles a partial failure: if setup throws after registering a listener, the catch block aborts the new controller, detaches the root and rethrows the error. This keeps a failed mount from quietly leaving part of its setup active.

How do you verify cleanup across repeated mounts?

First, try the lifecycle manually. Run the following sequence after installing the main program. The view disappears, repeated unmounting has no additional effect, and reopening restores it with its previous counts.

window.lifecycleDemo.unmount();
window.lifecycleDemo.unmount();
window.lifecycleDemo.mount();
window.lifecycleDemo.mount();

Then run this complete check in the same console. It depends on the main program above. Each cycle deliberately calls setup and cleanup twice, dispatches one event per target while mounted, and dispatches the same events while unmounted. A mismatch throws immediately.

(() => {
  const view = window.lifecycleDemo;
  if (!view) throw new Error("Run the main program first.");

  function check(actual, expected, label) {
    if (actual !== expected) {
      throw new Error(`${label}: expected ${expected}, got ${actual}`);
    }
  }

  for (let cycle = 0; cycle < 20; cycle += 1) {
    view.mount();
    view.mount();
    const before = view.counts;

    view.button.click();
    window.dispatchEvent(new Event("resize"));

    check(view.counts.clicks, before.clicks + 1, "Mounted click");
    check(view.counts.resizes, before.resizes + 1, "Mounted resize");

    view.unmount();
    view.unmount();
    const stopped = view.counts;

    // The button reference remains available while detached.
    view.button.click();
    window.dispatchEvent(new Event("resize"));

    check(view.counts.clicks, stopped.clicks, "Unmounted click");
    check(view.counts.resizes, stopped.resizes, "Unmounted resize");
  }

  view.mount();
  console.log("Passed 20 mount/unmount cycles.");
})();

The synthetic resize event makes the expected change deterministic without moving the window. The detached-button check is useful because this example deliberately retains the button for remounting. Both checks must pass: testing only the visible button would leave the window listener unexamined. The test adds 20 to each counter and finishes with the view mounted.

For direct inspection, Chrome provides getEventListeners(object) in the DevTools console. It returns the listeners registered on the specified object, grouped by event type. This is a DevTools utility, so keep it out of application code. Chrome Console Utilities API reference.

After running the main program, this inspection sequence compares registrations before and after cleanup. On the blank demonstration page, expect one button click listener and one window resize listener while mounted, then zero of each while unmounted.

(() => {
  const view = window.lifecycleDemo;
  if (!view) throw new Error("Run the main program first.");

  view.mount();
  console.log("Mounted", {
    button: (getEventListeners(view.button).click ?? []).length,
    window: (getEventListeners(window).resize ?? []).length
  });

  view.unmount();
  console.log("Unmounted", {
    button: (getEventListeners(view.button).click ?? []).length,
    window: (getEventListeners(window).resize ?? []).length
  });

  view.mount();
})();

On an existing application page, other code may own window listeners. Compare the before-and-after difference and inspect the callbacks rather than assuming the total must reach zero.

When should you use explicit removal instead?

Use removeEventListener() when integrating existing code that already stores callbacks or when the required environment does not support the listener signal option. Preserve the original target, event type, callback reference and capture value for cleanup. MDN documents that capture must match, while options such as passive do not determine removal matching. MDN’s removeEventListener() reference.

For example, store a bound callback once if setup uses bind(). Calling bind() again during cleanup produces a different function reference. Likewise, rewriting the same arrow function in the removal call does not recover the function registered earlier. Keep explicit cleanup beside setup so both evolve together.

The controller pattern covers the native listeners registered with its signal. If a view also creates timers, observers or library subscriptions, assign their documented cleanup operations to the same unmount boundary. Passing a signal to native listeners does not establish cleanup for unrelated resources.

What should you check if callbacks still multiply?

Use the symptom to narrow the investigation rather than changing several lifecycle methods at once.

Symptom Check next
One click increments twice Look for a second registration path or a second live view instance.
Reopening makes controls stop responding Check whether setup reused an aborted signal.
Counts change after unmount Check every event target and whether each registration received the intended signal.
Explicit removal changes nothing Compare the target, event type, callback identity and capture value.
The first action works but later actions do nothing Check whether the listener uses once: true.

The once option removes a listener after its first invocation. It can suit a genuinely single-use interaction, but it does not express a view’s mounted lifetime: the event might never occur before unmount. MDN describes both once and signal-based removal in its addEventListener() options reference.

When finished with the demonstration, run window.lifecycleDemo.destroy() to detach the view and remove its listeners. For your own view, keep the same practical contract: one active setup, repeatable cleanup, fresh registrations on reopening, and a test that exercises both visible controls and longer-lived event targets.

Sources