Markus Oberlehner

How to Clean Up Global Event Listeners, Intervals, and Third-party Libraries in Vue Components


Ideally, Vue components are self-contained pieces of UI without any observable side effects to matters outside the component’s scope. But unfortunately, that’s not always possible. For example, sometimes, we need to bind global event listeners, use setInterval, or initialize a particular third-party library inside of a component.


// Vue 3
export default defineComponent({
  name: 'SomeComponent',
  setup() {
    // Global event listener
    document.body.addEventListener('click', () => {
      // do something expensive ...
    }, { capture: true });

    // Interval
    setInterval(() => {
      // do something expensive ...
    }, 2000);

    // Third-party library
    let flatpickrElement = ref(null);
    onMounted(() => {
      flatpickr(flatpickrElement.value);
    });

    // ...
  },
});

// Vue 2
export default {
  name: 'SomeComponent',
  created() {
    // Global event listener
    document.body.addEventListener('click', () => {
      // do something expensive ...
    }, { capture: true });

    // Interval
    setInterval(() => {
      // do something expensive ...
    }, 2000);
  },
  mounted() {
    // Third-party library
    flatpickr(this.$refs.flatpickrElement);
  },
};

In cases like that, components must clean up after they are destroyed. If we don’t do this, all kinds of nasty things can happen, ranging from the malfunction of our apps to memory leaks.

Removing Global Event Listeners, Clearing Intervals, and Cleaning up Third-party Libraries

Let’s update our demo from above by adding hooks for cleaning up the global side effects our component causes.


// Vue 3
export default defineComponent({
  name: 'SomeComponent',
  setup() {
    // Global event listener
    let options = { capture: true };
    let callback = () => {
      // do something expensive ...
    };
    document.body.addEventListener('click', callback, options);
    onUnmounted(() => document.body.removeEventListener('click', callback, options));

    // Interval
    let intervalId = setInterval(() => {
      // do something expensive ...
    }, 2000);
    onUnmounted(() => clearInterval(intervalId));

    // Third-party library
    let flatpickrElement = ref(null);
    let flatpickrInstance;
    onMounted(() => {
      flatpickrInstance = flatpickr(flatpickrElement.value);
    });
    onUnmounted(() => flatpickrInstance.destroy());

    // ...
  },
});

// Vue 2
export default {
  name: 'SomeComponent',
  created() {
    // Global event listener
    let options = { capture: true };
    let callback = () => {
      // do something expensive ...
    };
    document.body.addEventListener('click', callback, options);
    this.$once('hook:beforeDestroy', () => document.body.removeEventListener('click', callback, options));

    // Interval
    let intervalId = setInterval(() => {
      // do something expensive ...
    }, 2000);
    this.$once('hook:beforeDestroy', () => clearInterval(intervalId));

    // Third-party library
    let flatpickrInstance;
    this.$once('hook:mounted', () => {
      flatpickrInstance = flatpickr(this.$refs.flatpickrElement);
    });
    this.$once('hook:beforeDestroy', () => flatpickrInstance.destroy());
  },
};

Every time our component is destroyed, the corresponding hooks are called, and the global side effects are cleaned up. That way, we don’t need to worry about memory leaks or global event listeners stacking up with every new component instance we create.


Do you want to learn more about advanced Vue.js techniques?

Register for the Newsletter of my upcoming book: Advanced Vue.js Application Architecture.


beforeDestroy and onUnmounted Hooks in @vue/test-utils Tests

One of my colleagues discovered that when testing components with the excellent @vue/test-utils package, the beforeDestroy and onUnmounted hooks are not called after a test! Although I didn’t expect it to be that way, this is by design. It is not a problem most of the time, but in some cases, this can lead to unexpected behavior with test cases interfering with each other because of a polluted global scope.

test("It should make magic happen.", () => {
  const wrapper = mount(SomeComponent);

  // ...

  expect(magicHappened).toBe(true);
  // Vue 3.
  wrapper.unmount();
  // Vue 2.
  wrapper.destroy();
});

Using this straightforward solution in those rare cases when this is a factor is usually fine. But people can easily forget it, so I prefer a more general solution.

I consider it a best practice to wrap third-party dependencies, and @vue/test-utils is no different. Doing so enables us to set default options that make sense for our application globally.

// Vue 3
// test/utils.js
import { merge } from "lodash";
import { mount as vueTestUtilsMount } from "@vue/test-utils";

let defaultOptions = {
  global: {
    mocks: {
      // Mocked plugins
      $t: (input) => input,
    },
  },
  // ...
};

export function mount(component, customOptions = {}) {
  let options = merge({}, defaultOptions, customOptions);
  return vueTestUtilsMount(component, options);
}

Furthermore, having a custom wrapper module for @vue/test-utils gives us the perfect place to configure a global behavior like this. Luckily, @vue/test-utils for Vue 2 has a built-in helper function that makes it very straightforward to trigger the beforeDestroy hook for every component initialized during testing.

// Vue 2
// test/utils.js
import { merge } from "lodash";
import { mount as vueTestUtilsMount, enableAutoDestroy } from "@vue/test-utils";

// See: https://vue-test-utils.vuejs.org/api/#enableautodestroy-hook
enableAutoDestroy(afterEach);

let defaultOptions = {
  mocks: {
    // Mocked plugins
    $t: (input) => input,
  },
  // ...
};

export function mount(component, customOptions = {}) {
  let options = merge({}, defaultOptions, customOptions);
  return vueTestUtilsMount(component, options);
}

Unfortunately, it seems that this hook has been removed in @vue/test-utils for Vue 3. So we need to implement the functionality ourselves.

// Vue 3
// test/utils.js
import { merge } from "lodash";
import { mount as vueTestUtilsMount } from "@vue/test-utils";

let defaultOptions = {
  global: {
    mocks: {
      // Mocked plugins
      $t: (input) => input,
    },
  },
  // ...
};

let wrappers = new Set();
afterEach(() => {
  wrappers.forEach((wrapper) => wrapper.unmount());
  wrappers.clear();
});

export function mount(component, customOptions = {}) {
  let options = merge({}, defaultOptions, customOptions);
  let wrapper = vueTestUtilsMount(component, options);
  wrappers.add(wrapper);

  return wrapper;
}

Wrapping It Up

What humanity is experiencing at a global scale right now is also true in programming. If we make a mess without taking responsibility for cleaning up after ourselves, bad things will happen.