Suggest an editImprove this articleRefine the answer for “Custom directives in Vue.js”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Custom directives in Vue.js** let you attach reusable DOM manipulation logic directly to elements in templates. They handle imperative tasks (focus, scroll, third-party lib setup) where components or composables would add unnecessary complexity. Data flows in via `binding.value`; Vue calls `mounted`, `updated`, and `unmounted` automatically. ```typescript const vAutoFocus = { mounted(el: HTMLElement) { el.focus() } } // Usage: <input v-auto-focus /> ``` **Key point:** use directives for direct DOM access. For reactive state and shared logic, use composables.Shown above the full answer for quick recall.Answer (EN)Image**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` (like `v-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 ```vue <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 ```typescript // 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 ```typescript 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`:** ```typescript // 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:** ```typescript // 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:** ```typescript // 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:** ```typescript // 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 `vAutoResize` and `vInfiniteScroll`, used inside PrimeVue and Quasar. - **Element Plus** uses `v-loading` for spinner overlays in enterprise dashboards. - **Nuxt UI** uses `v-motion` for animations without wrapping every element in a separate component. - **Global registration** goes in `main.ts` via `app.directive('name', def)` for directives used across the whole app. - **Local registration** in `<script setup>` works automatically: any variable starting with `v` (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. ```typescript // 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) }, } ``` ```vue <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. ```vue <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 ```typescript // 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) }, } ``` ```vue <!-- 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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.