A Vue 3 `v-click-outside` directive leaks document listeners and sometimes calls an outdated handler; how should its hooks be written to fix both?
answer
- one listener per element
- add in mounted, remove in unmounted
- the handler can change
- keep state keyed by el
- shorthand adds on every update
basics
~20 sRegister one document listener in mounted and store it keyed by the element, update the stored callback in updated when binding.value changes, and remove the listener in unmounted. Leaks come from adding in updated or never removing; stale calls come from capturing binding.value once.
solid answer
~40 sA click-outside directive needs a document-level listener that lives exactly as long as the element. Create it in `mounted` and store it in a `WeakMap` keyed by `el`, together with the current callback. The listener checks `el.contains(event.target)` and otherwise calls the **stored** callback. In `updated`, replace the stored callback with `binding.value`, because the parent may pass a new function, for example an inline arrow created on each render. In `unmounted`, call `removeEventListener` with the same function reference and delete the entry. The two bugs map to two mistakes: writing the directive as a function shorthand or adding listeners in `updated` registers a new listener on every re-render and never removes them, and closing over `binding.value` in `mounted` keeps calling the first handler forever.
code
vue · 13 lines<script setup>
import { ref } from 'vue'
import { vClickOutside } from './directives/clickOutside'
const open = ref(false)
</script>
<template>
<div v-if="open" v-click-outside="() => (open = false)" class="menu">
<slot />
</div>
<button @click.stop="open = true">Menu</button>
</template>go deeper
Remember that anything a directive adds in mounted must be removed in unmounted.
Explain why the function shorthand and updated-hook registration multiply listeners.
Diagnose leaked listeners and stale handlers, and fix them with element-keyed state and a refreshed callback.
Decide whether shared DOM-event directives live in a vetted internal package with cleanup tests.
## The scenario A dropdown uses `v-click-outside="close"` to close when the user clicks elsewhere. After a while the app slows down, and profiling shows hundreds of `click` listeners on `document`. Occasionally a click outside calls a handler that closes the wrong menu or works on stale data. Both symptoms trace back to how the directive's hooks were written. ## Why listeners leak The usual leaky version looks like this: ```js // leaks: runs on mounted AND updated, never removes const vClickOutside = (el, binding) => { document.addEventListener('click', (e) => { if (!el.contains(e.target)) binding.value(e) }) } ``` 1. A **function directive** is registered for both `mounted` and `updated`. `updated` runs every time the owning component re-renders, so each re-render adds another listener. 2. Each listener is a **new arrow function**, so even an attempted `removeEventListener` would not match it. 3. Nothing runs in `unmounted`, so listeners outlive the element and keep a detached node and its closure alive. ## Why a handler goes stale If the directive is fixed to add the listener only in `mounted`, but the listener closes over `binding.value` from that moment, it keeps calling the **first** function it saw. When the parent passes `@close`-style inline arrows or switches handlers based on state, each render supplies a new function that the listener never sees. `binding.value` is a fresh snapshot per hook call, not a live reference. ## A correct implementation ```js const state = new WeakMap() export const vClickOutside = { mounted(el, binding) { const entry = { handler: binding.value, listener: (e) => { if (!el.contains(e.target)) entry.handler(e) } } state.set(el, entry) document.addEventListener('click', entry.listener) }, updated(el, binding) { const entry = state.get(el) if (entry) entry.handler = binding.value }, unmounted(el) { const entry = state.get(el) if (entry) document.removeEventListener('click', entry.listener) state.delete(el) } } ``` The rules it follows: - **Pair every add with a remove.** `mounted` adds, `unmounted` removes, using the same function reference. - **Indirect through mutable state.** The listener calls `entry.handler`, which `updated` refreshes, so the latest callback always runs. - **Key state by element.** A `WeakMap` keeps per-element data without a shared module variable and without mutating `binding`. - **Use the object form.** Only `mounted`, `updated` and `unmounted` are defined, so nothing extra runs on updates. ## Checking the fix | Symptom | Cause | Fixed by | |---|---|---| | Listener count grows per re-render | adding in `updated` or function shorthand | object form, add only in `mounted` | | Listeners remain after the dropdown closes | no `unmounted` cleanup | `removeEventListener` in `unmounted` | | Old callback runs | closure over `binding.value` at mount | store the handler, refresh it in `updated` | In the browser's developer tools you can inspect the listeners registered on `document` before and after opening and closing the dropdown several times; the count should return to its starting value. ## Related considerations - The directive's hooks do not run during server rendering, so the listener is naturally client-only. - Put the directive on a plain element, such as the dropdown's wrapper `<div>`, rather than on a component, so `el` is the node you mean. - If the directive must accept options, pass an object (`{ handler, exclude }`) and refresh the whole object in `updated`. ## Proving the fix in a test A directive that manages global listeners deserves a regression test, because leaks are invisible in normal use: 1. Spy on `document.addEventListener` and `document.removeEventListener`. 2. Mount a small component that renders an element with `v-click-outside`. 3. Change unrelated state several times to force re-renders. 4. Assert that `addEventListener` was called **once** for this element. 5. Unmount the component and assert that `removeEventListener` received the same function reference. 6. Dispatch a click on `document.body` after changing the bound handler and assert that the **latest** handler ran. Steps 4 and 5 catch the leak; step 6 catches the stale closure. Together they pin down both halves of the bug described in the question.
- Why is `document.removeEventListener('click', (e) => ...)` in a Vue 3 directive's `unmounted` hook ineffective?`removeEventListener` only removes a listener registered with the same function reference. A new arrow written in `unmounted` is a different function, so nothing is removed. Store the exact listener created in `mounted`, in a `WeakMap` keyed by `el`, and pass that reference when removing.
- In a Vue 3 click-outside directive, why must `updated` refresh the stored handler even if the logic did not change?The parent often passes an inline arrow or a handler chosen from state, which is a new function on each render. The document listener was created once in `mounted`; unless `updated` copies the latest `binding.value` into shared state, the listener keeps calling the first function and its stale closure.
saying these in an interview costs you the question
- Writes click-outside as a function shorthand directive
- Adds the document listener in updated
- Removes the listener with a freshly written arrow function
- Captures binding.value once in mounted and reuses it forever
- Keeps per-element listeners in one module-level variable