--- title: Clean Up Side Effects in Directive unmounted Hook impact: HIGH impactDescription: Failing to clean up intervals, event listeners, and subscriptions in directives causes memory leaks type: gotcha tags: [vue3, directives, memory-leak, cleanup, unmounted, event-listeners] --- # Clean Up Side Effects in Directive unmounted Hook **Impact: HIGH** - A common and critical mistake when creating custom directives is forgetting to clean up intervals, event listeners, and other side effects in the `unmounted` hook. This causes memory leaks and ghost handlers that continue running after the element is removed from the DOM. The key to avoiding such bugs is always implementing the `unmounted` hook to clean up any resources created in `mounted` or other lifecycle hooks. ## Task Checklist - [ ] Always pair resource creation in `mounted` with cleanup in `unmounted` - [ ] Store references to intervals, timeouts, and listeners for later cleanup - [ ] Use `el.dataset` or WeakMap to share data between directive hooks - [ ] Test that directives properly clean up when elements are removed (v-if toggling) **Incorrect:** ```javascript // WRONG: No cleanup - memory leak! const vPoll = { mounted(el, binding) { // This interval runs forever, even after element is removed! setInterval(() => { console.log('polling...') binding.value?.() }, 1000) } } // WRONG: Event listener persists after unmount const vClickOutside = { mounted(el, binding) { document.addEventListener('click', (e) => { if (!el.contains(e.target)) { binding.value() } }) // No cleanup - listener stays attached to document! } } ``` **Correct:** ```javascript // CORRECT: Store reference and clean up const vPoll = { mounted(el, binding) { // Store interval ID on the element for later cleanup el._pollInterval = setInterval(() => { console.log('polling...') binding.value?.() }, 1000) }, unmounted(el) { // Clean up the interval if (el._pollInterval) { clearInterval(el._pollInterval) delete el._pollInterval } } } // CORRECT: Named function for proper removal const vClickOutside = { mounted(el, binding) { el._clickOutsideHandler = (e) => { if (!el.contains(e.target)) { binding.value() } } document.addEventListener('click', el._clickOutsideHandler) }, unmounted(el) { if (el._clickOutsideHandler) { document.removeEventListener('click', el._clickOutsideHandler) delete el._clickOutsideHandler } } } ``` ## Using WeakMap for Cleaner State Management ```javascript // BEST: Use WeakMap to avoid polluting element properties const pollIntervals = new WeakMap() const clickHandlers = new WeakMap() const vPoll = { mounted(el, binding) { const intervalId = setInterval(() => { binding.value?.() }, binding.arg || 1000) pollIntervals.set(el, intervalId) }, unmounted(el) { const intervalId = pollIntervals.get(el) if (intervalId) { clearInterval(intervalId) pollIntervals.delete(el) } } } const vClickOutside = { mounted(el, binding) { const handler = (e) => { if (!el.contains(e.target)) { binding.value() } } clickHandlers.set(el, handler) document.addEventListener('click', handler) }, unmounted(el) { const handler = clickHandlers.get(el) if (handler) { document.removeEventListener('click', handler) clickHandlers.delete(el) } } } ``` ## Complete Example with Multiple Resources ```javascript const vAutoScroll = { mounted(el, binding) { const state = { intervalId: null, resizeObserver: null, scrollHandler: null } // Set up polling state.intervalId = setInterval(() => { el.scrollTop = el.scrollHeight }, binding.value?.interval || 100) // Set up resize observer state.resizeObserver = new ResizeObserver(() => { el.scrollTop = el.scrollHeight }) state.resizeObserver.observe(el) // Set up scroll listener state.scrollHandler = () => { // Track user scroll } el.addEventListener('scroll', state.scrollHandler) // Store all state for cleanup el._autoScrollState = state }, unmounted(el) { const state = el._autoScrollState if (!state) return // Clean up everything if (state.intervalId) { clearInterval(state.intervalId) } if (state.resizeObserver) { state.resizeObserver.disconnect() } if (state.scrollHandler) { el.removeEventListener('scroll', state.scrollHandler) } delete el._autoScrollState } } ``` ## Testing Directive Cleanup ```javascript // Test that cleanup works properly import { mount } from '@vue/test-utils' import { ref, nextTick } from 'vue' const vTrackInterval = { mounted(el) { el._interval = setInterval(() => {}, 100) window.__activeIntervals = (window.__activeIntervals || 0) + 1 }, unmounted(el) { clearInterval(el._interval) window.__activeIntervals-- } } test('directive cleans up interval on unmount', async () => { const show = ref(true) const wrapper = mount({ template: `
`, directives: { 'track-interval': vTrackInterval }, setup: () => ({ show }) }) expect(window.__activeIntervals).toBe(1) show.value = false await nextTick() expect(window.__activeIntervals).toBe(0) }) ``` ## Reference - [Vue.js Custom Directives - Directive Hooks](https://vuejs.org/guide/reusability/custom-directives#directive-hooks) - [Vue School - The Directive's unmounted Hook](https://vueschool.io/lessons/vue-3-the-directive-s-unmounted-hook)