Custom directives in Vue.js
Custom directives in Vue.js let you register reusable functions that directly manipulate DOM elements in templates, bypassing component logic for imperative tasks like focus management or third-party library integration.
Theory
TL;DR
- Directives are like custom kitchen gadgets: Vue's built-ins (
v-if,v-for) handle common tasks, you craft your own for specialized DOM work. - Main difference: components manage data and UI declaratively; directives imperatively modify the raw DOM element.
- Data flows in via
binding.value(likev-tooltip="'Save'"), Vue calls your hooks automatically at the right lifecycle moment. - Use when you need direct element access (focus, scroll, third-party lib init) and components feel like overkill.
- For reactive state and business logic, use composables, not directives.
Quick example
<script setup lang="ts">
// Local directive - registered automatically by the 'v' prefix
const vAutoFocus = {
mounted(el: HTMLElement) {
el.focus() // Called once, right after the element enters the DOM
}
}
</script>
<template>
<!-- Input gains focus immediately on mount - no ref needed -->
<input v-auto-focus placeholder="This focuses on load" />
</template>The directive hooks into Vue's lifecycle and runs focus() directly on the element. No ref, no onMounted inside the component, no watchers.
Key difference
Components live in the reactive world: they describe what the UI should look like based on state. Directives sit one level below that. They receive the actual DOM node and act on it directly. You pass data through binding.value, but the directive itself has no component state and no this. It is a function that runs on an element at specific lifecycle moments - nothing more.
When to use
- Imperative DOM ops (focus,
scrollIntoView,select()) where doing it inside a component adds unnecessary boilerplate. - Third-party library integration like Tippy.js, clipboard API, or charting libs that require a real DOM node to attach to.
- Reusable element behavior across the app: truncating text, lazy-loading images, detecting clicks outside an element.
- Skip directives when you need reactive state, computed values, or data shared between components. That is composable territory.
How Vue handles directives internally
Vue's template compiler scans your <template> at build time and transforms v-myDir into a vnode.directive object attached to the virtual node. At runtime, during the patch() cycle (Vue 3's diff and update pass), Vue calls your hook functions on the real DOM el, passing binding (value, arg, modifiers), vnode, and prevVnode. Everything runs in the browser's main thread, integrated into the same reactivity scheduler as components.
The binding object
// Template: <div v-my-directive:arg.mod1.mod2="someValue">
// binding contains:
{
value: someValue, // What you passed in the template
oldValue: ..., // Previous value, available in updated hook
arg: 'arg', // String after the colon
modifiers: { mod1: true, mod2: true },
instance: ..., // The component instance
dir: ..., // The directive definition object
}Directive lifecycle hooks
const myDirective = {
created(el, binding, vnode) {}, // Before element attributes are applied
beforeMount(el, binding, vnode) {}, // Before element is inserted into DOM
mounted(el, binding, vnode) {}, // After element is inserted into DOM
beforeUpdate(el, binding, vnode, prevVnode) {}, // Before parent component updates
updated(el, binding, vnode, prevVnode) {}, // After parent component updates
beforeUnmount(el, binding, vnode) {}, // Before element is removed
unmounted(el, binding, vnode) {}, // After element is removed
}In practice, the vast majority of directives only need three hooks: mounted, updated, and unmounted. The full list exists for edge cases.
Common mistakes
Not cleaning up in unmounted:
// Wrong: event listener leaks memory on every SPA navigation
mounted(el) {
el.addEventListener('click', handler)
}
// No unmounted hook - after 1000 navigations, 1000 listeners on document
// Right:
mounted(el) {
el.addEventListener('click', handler)
},
unmounted(el) {
el.removeEventListener('click', handler)
}Trying to access component state directly:
// Wrong: directives have no 'this', no setup scope
mounted(el, binding) {
this.myData = 'value' // ReferenceError in strict mode
}
// Right: pass data through binding.value
// <div v-my-dir="myReactiveValue">
mounted(el, binding) {
doSomething(binding.value)
}Re-running expensive operations on every parent re-render:
// Wrong: reinitializes a library every time anything updates
updated(el, binding) {
initExpensiveLib(el, binding.value)
}
// Right: skip the work if nothing changed
updated(el, binding) {
if (binding.value !== binding.oldValue) {
initExpensiveLib(el, binding.value)
}
}Registering globally without a prefix:
// Risk: name collision with future Vue built-ins
app.directive('focus', vFocus)
// Safer:
app.directive('app-focus', vFocus)Real-world usage
- VueUse ships 50+ directives like
vAutoResizeandvInfiniteScroll, used inside PrimeVue and Quasar. - Element Plus uses
v-loadingfor spinner overlays in enterprise dashboards. - Nuxt UI uses
v-motionfor animations without wrapping every element in a separate component. - Global registration goes in
main.tsviaapp.directive('name', def)for directives used across the whole app. - Local registration in
<script setup>works automatically: any variable starting withv(e.g.vAutoFocus) is treated as a directive.
Follow-up questions
Q: What is the hook execution order during a component update?
A: beforeUpdate fires first, then updated. For nested elements, hooks run bottom-up for children and top-down for parents.
Q: How do modifiers work internally?
A: The compiler parses v-dir.foo.bar and passes { foo: true, bar: true } as binding.modifiers. Your hook reads them as a plain object. Dynamic args like v-dir:[dynamicArg] are resolved to a string at runtime.
Q: What is the difference between local and global directive registration?
A: Local means declaring a v-prefixed variable in <script setup>, scoped to that one component. Global means app.directive('name', def) in main.ts, available everywhere but added to the app's namespace.
Q: Can a directive access the component instance?
A: Yes, via binding.instance. But this creates tight coupling between the directive and a specific component shape. Passing data through binding.value is almost always the better path.
Q: (Senior) How do you optimize a directive applied to 1000+ list items?
A: First, skip hooks when binding.value === binding.oldValue. Debounce heavy DOM ops. Store cleanup state directly on el (like el._cleanup) instead of external Maps that hold references and prevent GC. Avoid getCurrentInstance() inside directives as it disables tree-shaking for the whole module.
Examples
Click-outside detection
The most common real-world directive - closing dropdowns or modals when a user clicks outside.
// directives/clickOutside.ts
export const vClickOutside = {
mounted(el: HTMLElement, binding: { value: () => void }) {
el._clickOutsideHandler = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value() // Call the handler passed from the template
}
}
document.addEventListener('click', el._clickOutsideHandler)
},
unmounted(el: HTMLElement) {
// This line is the whole point - skip it and you have a leak
document.removeEventListener('click', el._clickOutsideHandler)
},
}<template>
<div v-click-outside="closeDropdown" class="dropdown">
<!-- Dropdown content -->
</div>
</template>The unmounted cleanup is not optional. Skip it and every navigation in your SPA quietly adds another listener to document.
Tooltip with Tippy.js
Directives are the right abstraction for wrapping imperative third-party APIs.
<script setup lang="ts">
import tippy from 'tippy.js'
const vTooltip = {
mounted(el: HTMLElement, binding) {
tippy(el, { content: binding.value, trigger: 'hover focus' })
},
updated(el: HTMLElement, binding) {
// Update content reactively - no full remount needed
el._tippy?.setContent(binding.value)
},
unmounted(el: HTMLElement) {
el._tippy?.destroy()
}
}
</script>
<template>
<button v-tooltip="tooltipText">Save</button>
</template>This is the pattern VueUse-style libraries use. The directive hides the imperative Tippy API and exposes a clean declarative interface. When tooltipText changes, updated fires and setContent swaps it without destroying and recreating the instance.
Lazy image loading with IntersectionObserver
// directives/lazyLoad.ts
export const vLazyLoad = {
mounted(el: HTMLImageElement, binding: { value: string }) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value // Load only when visible in viewport
observer.disconnect() // Stop watching after first load
}
})
observer.observe(el)
},
}<!-- Register globally in main.ts, then: -->
<img v-lazy-load="imageUrl" alt="Product photo" />Each directive instance gets its own observer that disconnects after the image loads. No wasted requests for images below the fold, no shared state between instances.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.