15 — Custom Directives
Directive Hook Lifecycle
directive-hooks.ts
import { type Directive, type DirectiveBinding } from 'vue'
// ── Directive hooks fire in parallel with component lifecycle ──
// The directive object can implement any subset of these hooks.
const myDirective: Directive = {
// ── created: before the element's attributes/event listeners are applied ──
// Useful for setting up something that must exist before Vue patches attrs.
created(el, binding, vnode, prevVnode) {
// el: the DOM element (always a real element, even for components)
// binding.value: the value passed to v-directive="value"
// binding.oldValue: previous value (only in beforeUpdate/updated)
// binding.arg: the argument (v-directive:foo → "foo")
// binding.modifiers: object of modifiers (v-directive.bar → { bar: true })
// binding.instance: the component instance using the directive
// vnode: the virtual node of the element
},
// ── beforeMount: before the element is inserted into the DOM ──
// Element exists in memory but not in document yet. Can't measure size.
beforeMount(el, binding) {},
// ── mounted: element is in the DOM ──
// Safe to measure (getBoundingClientRect), attach event listeners, init libraries.
mounted(el, binding) {},
// ── beforeUpdate: before the element's VNode updates ──
// binding.oldValue is available here (the value before the update).
beforeUpdate(el, binding, vnode, oldVnode) {},
// ── updated: after the element's VNode (and children) have updated ──
updated(el, binding) {},
// ── beforeUnmount: before the element is removed from DOM ──
// Element is still in the DOM — cleanup that needs the element can run here.
beforeUnmount(el) {},
// ── unmounted: element removed from DOM ──
// Final cleanup — remove event listeners, destroy library instances.
unmounted(el) {},
}
// ── Shorthand: if you only need mounted + updated, pass a function ──
// This function runs in both mounted and updated hooks.
const vFocus: Directive = (el, binding) => {
if (binding.value) el.focus()
}
Production Directive — v-lazy with IntersectionObserver
vLazy.ts
import { type Directive } from 'vue'
// ── v-lazy: lazy-load images when they scroll into view ──
// Usage: <img v-lazy="imageUrl" />
// Only sets src when the element intersects the viewport, reducing initial load.
export const vLazy: Directive<HTMLImageElement, string> = {
mounted(el, binding) {
// ── Store the target src, don't set it yet ──
el.dataset.src = binding.value
// ── IntersectionObserver: fires callback when element enters viewport ──
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Element is visible — set the src to trigger the image load
el.src = el.dataset.src!
// Stop observing once loaded (one-shot, not continuous)
observer.unobserve(el)
}
})
},
{
// ── rootMargin: start loading 50px before element enters viewport ──
// Reduces visible "pop-in" — image is loading before user sees the empty slot.
rootMargin: '50px',
threshold: 0.01, // trigger when 1% of element is visible
}
)
observer.observe(el)
// ── Store observer for cleanup in unmounted ──
el._lazyObserver = observer
},
updated(el, binding) {
// ── Handle dynamic src changes ──
if (binding.value !== binding.oldValue) {
el.dataset.src = binding.value
el.src = binding.value // already in view, update immediately
}
},
unmounted(el) {
// ── Cleanup: disconnect observer to prevent memory leaks ──
el._lazyObserver?.disconnect()
},
}
// ── Augment HTMLElement for TypeScript ──
declare global {
interface HTMLElement {
_lazyObserver?: IntersectionObserver
}
}
Production Directive — v-copy Clipboard
vCopy.ts
import { type Directive } from 'vue'
// ── v-copy: copy text to clipboard on click ──
// Usage: <button v-copy="textToCopy">Copy</button>
// Supports modifier: v-copy.toast to show a toast on success.
export const vCopy: Directive<HTMLElement, string> = {
mounted(el, binding) {
// ── Store the click handler so we can remove it on unmount ──
el._copyHandler = async () => {
const text = binding.value
try {
// ── Clipboard API (modern, secure context only) ──
await navigator.clipboard.writeText(text)
// ── Optional: emit a custom event for the component to listen to ──
el.dispatchEvent(new CustomEvent('copied', { detail: { text } }))
// ── Modifier: show toast if v-copy.toast ──
if (binding.modifiers.toast) {
showToast('Copied to clipboard')
}
} catch (e) {
// ── Fallback: execCommand (deprecated but works in non-secure contexts) ──
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
}
el.addEventListener('click', el._copyHandler)
},
// ── updated: handle dynamic value changes (binding.value updated) ──
// The click handler reads binding.value at click time, so if we re-create
// the handler on every update, we always have the latest value.
updated(el, binding) {
if (binding.value !== binding.oldValue) {
el.removeEventListener('click', el._copyHandler!)
el._copyHandler = createHandler(binding)
el.addEventListener('click', el._copyHandler)
}
},
unmounted(el) {
el.removeEventListener('click', el._copyHandler!)
},
}
function createHandler(binding: DirectiveBinding<string>) {
return async () => {
await navigator.clipboard.writeText(binding.value)
}
}
declare global {
interface HTMLElement {
_copyHandler?: () => void
}
}
Directive Arguments and Modifiers
DirectiveArgs.vue
<script setup>
// ── v-permission:role.admin="user" ────────────────────
// binding.arg = "role" (the part after the colon)
// binding.modifiers = { admin: true } (the dots)
// binding.value = user (the expression result)
const vPermission = {
mounted(el, binding) {
const { arg, modifiers, value } = binding
// arg = 'role', modifiers = { admin: true }, value = user object
if (arg === 'role' && modifiers.admin && value.role !== 'admin') {
el.remove() // remove element if user is not admin
}
// Or: el.style.display = 'none' for hide instead of remove
},
}
</script>
<template>
<div v-permission:role.admin="user">Admin only content</div>
<!-- arg: 'role', modifiers: { admin: true }, value: user -->
</template>
💡 Tips & Tricks
tips.ts
// ── 1. Directive naming: v- prefix in templates, vXxx in script ──
// export const vFocus = { ... } → used as <input v-focus />
// The 'v' prefix is automatic in templates; the variable must start with 'v'.
// ── 2. Directives on components: applied to the root element ──
// <MyComp v-focus /> → v-focus runs on MyComp's root DOM element.
// With fragments (multiple roots), a warning is issued (ambiguous target).
// ── 3. Function shorthand: mounted + updated only ──
// const vColor = (el, binding) => { el.style.color = binding.value }
// Equivalent to { mounted(el, b) {...}, updated(el, b) {...} }
// ── 4. Custom events from directives ──
// el.dispatchEvent(new CustomEvent('my-event', { detail: payload }))
// Parent listens: <div v-my @my-event="handler" />
// ── 5. Global registration: app.directive() ──
// app.directive('lazy', vLazy) → available in ALL components without import.
// Use for app-wide directives (v-focus, v-permission); local for one-off.
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. Directives don't receive the component instance via `this` ──
// Use binding.instance to access the component that uses the directive.
// `this` inside a directive hook is undefined — not the component instance.
// ── 2. Directives on components with fragments (multiple roots) warn ──
// Vue can't know which root to apply the directive to.
// Use a single root element, or apply the directive to a specific element inside.
// ── 3. Cleanup in unmounted is MANDATORY for observers/listeners ──
// Without cleanup: memory leaks, stale callbacks firing on detached elements.
// Always store references (observer, handler) on el and remove in unmounted.
// ── 4. binding.value can be any type — check before using ──
// v-my="42" → binding.value = 42 (number)
// v-my="user.name" → binding.value = string
// v-my="{ a: 1 }" → binding.value = object
// Validate the type inside the directive to avoid runtime errors.
// ── 5. Directive hooks fire AFTER component lifecycle hooks ──
// mounted hook: component's onMounted fires first, then directive's mounted.
// If the directive needs component state initialized in onMounted, it's ready.
// ── 6. v-bind='$attrs' can conflict with directives on the same element ──
// If parent passes an attribute that the directive also modifies, order matters.
// Directives run after attribute patching — directive wins.
🧠 Spot the Bug
A v-tooltip directive works initially but leaks memory — tooltips accumulate in the DOM.
TooltipBug.ts
export const vTooltip = {
mounted(el, binding) {
const tooltip = document.createElement('div')
tooltip.textContent = binding.value
tooltip.className = 'tooltip'
document.body.appendChild(tooltip)
el.addEventListener('mouseenter', () => {
tooltip.style.display = 'block'
})
el.addEventListener('mouseleave', () => {
tooltip.style.display = 'none'
})
// ❌ No cleanup — tooltip div and event listeners persist after unmount
},
}
Answer
The tooltip div is appended to document.body and never removed. Event listeners are added but never removed. When the element unmounts, the tooltip div and listeners persist — a memory leak that accumulates across navigations.
Fix — store references and clean up in unmounted:
TooltipFixed.ts
export const vTooltip = {
mounted(el, binding) {
const tooltip = document.createElement('div')
tooltip.textContent = binding.value
tooltip.className = 'tooltip'
tooltip.style.display = 'none'
document.body.appendChild(tooltip)
const show = () => { tooltip.style.display = 'block' }
const hide = () => { tooltip.style.display = 'none' }
el.addEventListener('mouseenter', show)
el.addEventListener('mouseleave', hide)
// Store for cleanup
el._tooltip = { tooltip, show, hide }
},
unmounted(el) {
const { tooltip, show, hide } = el._tooltip
el.removeEventListener('mouseenter', show)
el.removeEventListener('mouseleave', hide)
document.body.removeChild(tooltip) // remove the DOM element
},
}
The lesson: every DOM element created, event listener added, or observer created in a directive must be cleaned up in unmounted. Without cleanup, the resources outlive the element, causing memory leaks.