Vue.js 62 🟢 Writing Custom Directives (Lifecycle Hooks: created, beforeMount, mounted, updated)
Custom directives are Vue’s escape hatch for low-level DOM manipulation. They exist for the cases that components and composables cannot cover: focusing an input when it appears, integrating a third-party DOM library that expects a raw element, or attaching an event listener that must be cleaned up on unmount. The directive is defined as an object with lifecycle hooks that mirror the component lifecycle, and each hook receives the element the directive is bound to along with a binding object that carries the value, argument, and modifiers passed from the template .
Vue 3 unified the directive hooks with the component lifecycle hooks. The Vue 2 names—bind, inserted, update, componentUpdated, unbind—were renamed to beforeMount, mounted, beforeUpdate, updated, and unmounted. Two new hooks were added: created, which runs before the element’s attributes or event listeners are applied, and beforeUnmount, which runs before the element is removed. The update hook was removed entirely because it was too similar to updated . This unification means that a developer who knows the component lifecycle already knows the directive lifecycle—the names and the order are the same.
Key point: A custom directive is an object with optional lifecycle hooks: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, and unmounted. Each hook receives el (the bound element), binding (value, oldValue, arg, modifiers, instance, dir), vnode, and prevVnode. The directive should only be used when the behavior requires direct DOM access; declarative templating with built-in directives is preferred when possible.
Why the directive lifecycle mirrors the component lifecycle
The mental model problem. In Vue 2, the directive hooks had names that did not match the component hooks: bind corresponded to beforeMount, inserted to mounted, componentUpdated to updated, and unbind to unmounted. A developer who understood component lifecycles had to learn a second vocabulary for directives. The Vue 3 migration guide states the reason for the change directly: the hooks were renamed “to better align with the component lifecycle” and to make them “easier to reason about and remember” .
The created hook problem. The new created hook is the only directive hook that runs before the element’s attributes or event listeners are applied. This is the hook for attaching an event listener that must be registered before Vue’s own v-on listeners, because the order of listener registration determines the order of invocation. The Vue documentation notes that this is useful when the directive needs to attach a listener that must run before the normal v-on event listeners .
The beforeUpdate and updated problem. The beforeUpdate hook runs before the parent component updates, and updated runs after the parent component and all its children have updated. The oldValue in the binding is only available in these two hooks. This is how a directive can compare the previous value to the current value and skip the DOM manipulation if nothing changed. Without this comparison, every parent re-render would trigger the directive’s DOM update, even when the bound value is identical.
The fragment problem. Vue 3 supports fragments, which means a component can have multiple root nodes. When a custom directive is applied to a multi-root component, the directive is ignored and a warning is logged. The directive hooks are passed as props to the component, and the component must apply them to a specific element. The Vue documentation warns about this explicitly: “When applied to a multi-root component, a directive will be ignored and a warning will be logged” . This is a behavior change from Vue 2, where a component had exactly one root element.
a. The basic directive: mounted and the el argument
The simplest custom directive is one that manipulates the DOM when the element is inserted. The mounted hook is the most commonly used hook because it runs after the element is in the document, which is when most DOM operations are safe.
// A directive that focuses an input when it is mounted
const vFocus = {
// mounted runs after the element is inserted into the DOM
// el is the element the directive is bound to
mounted(el) {
// Call the native focus method on the element
el.focus()
},
}
// Usage in a template:
// <input v-focus />
The el argument is the actual DOM element, not a Vue component instance. It can be used to call any native method, set any style, or attach any event listener. The directive does not need to know anything about the component that uses it; it only needs the element .
The function shorthand is available for directives that only need mounted and updated with the same behavior. A function directive is called on both hooks, which is useful for directives that apply a style or attribute based on a value:
// Function shorthand: called on mounted and updated
const vColor = (el, binding) => {
// Set the text color to the value passed to the directive
el.style.color = binding.value
}
// Equivalent to:
const vColorFull = {
mounted(el, binding) {
el.style.color = binding.value
},
updated(el, binding) {
el.style.color = binding.value
},
}
The function shorthand is the recommended form when the directive’s behavior is the same on mount and update. It reduces the boilerplate and makes the intent clearer .
b. The binding object: value, arg, and modifiers
The binding object is the second argument to every hook. It carries everything the template passed to the directive: the value after the =, the argument after the :, and the modifiers after the ..
// A directive with a value, an argument, and modifiers
// Usage: <p v-highlight:blue.bold="textColor">...</p>
const vHighlight = {
mounted(el, binding) {
// binding.value is the value passed after the equals sign
// In v-highlight:blue.bold="textColor", value is the textColor variable
const value = binding.value
// binding.arg is the argument after the colon
// In v-highlight:blue.bold, arg is "blue"
const color = binding.arg || 'yellow'
// binding.modifiers is an object of the modifiers
// In v-highlight:blue.bold, modifiers is { bold: true }
if (binding.modifiers.bold) {
el.style.fontWeight = 'bold'
}
// Apply the background color from the value or the arg
el.style.backgroundColor = value || color
},
}
The binding.value is the value of the expression, not the expression itself. In v-my-directive="1 + 1", the value is 2, not the string "1 + 1". The expression string is not available in Vue 3; it was removed from the binding object . The binding.oldValue is only available in beforeUpdate and updated, and it contains the previous value regardless of whether the value changed .
The binding.instance is the component instance where the directive is used. The Vue documentation recommends keeping directives independent of the component instance, because accessing the instance is often a sign that the directive should be a component instead. However, when the directive genuinely needs to interact with the component—for example, to call a method—the instance is available through binding.instance .
The binding.dir is the directive definition object itself. It is rarely used, but it allows the directive to access its own configuration if the definition object has additional properties.
c. The full lifecycle: created, beforeMount, mounted, updated
The full set of hooks covers the entire lifecycle of the element the directive is bound to. The order is fixed: created runs before the element’s attributes or event listeners are applied, beforeMount runs before the element is inserted, mounted runs after the element and its children are mounted, beforeUpdate runs before the parent component updates, updated runs after the parent and children have updated, beforeUnmount runs before the element is removed, and unmounted runs after the element is removed.
// A directive that logs each lifecycle stage
// This is useful for understanding the order and the arguments
const vLifecycle = {
// Runs before attributes and event listeners are applied
created(el, binding, vnode) {
console.log('created', el)
},
// Runs right before the element is inserted into the DOM
beforeMount(el, binding, vnode) {
console.log('beforeMount', el)
},
// Runs after the element and its children are mounted
mounted(el, binding, vnode) {
console.log('mounted', el)
},
// Runs before the parent component updates
// oldValue is available here
beforeUpdate(el, binding, vnode, prevVnode) {
console.log('beforeUpdate', binding.oldValue, binding.value)
},
// Runs after the parent and children have updated
// oldValue is available here
updated(el, binding, vnode, prevVnode) {
console.log('updated', binding.oldValue, binding.value)
},
// Runs before the parent component unmounts
beforeUnmount(el, binding, vnode) {
console.log('beforeUnmount', el)
},
// Runs after the parent component unmounts
unmounted(el, binding, vnode) {
console.log('unmounted', el)
},
}
The created hook is the earliest hook. It runs before the element’s attributes are applied, which means the element does not yet have its class, style, or event listeners. This is the hook for attaching a native event listener that must be registered before Vue’s own listeners, because the order of registration determines the order of invocation .
The beforeMount hook runs before the element is inserted into the DOM. The element exists, but it is not yet in the document. This is the hook for preparing the element—setting initial properties, storing references—before it becomes visible.
The mounted hook is the most commonly used. The element is in the document, its children are mounted, and the DOM is ready for manipulation. This is the hook for focusing an input, initializing a third-party library, or measuring the element’s size.
The beforeUpdate and updated hooks fire when the parent component re-renders. The oldValue in the binding allows the directive to compare the previous value to the current value and skip the update if nothing changed. This is the optimization that prevents the directive from doing unnecessary work on every parent re-render.
The beforeUnmount and unmounted hooks fire when the element is removed. This is the hook for cleaning up: removing event listeners, disposing third-party instances, and canceling timers. A directive that attaches a listener in mounted and does not remove it in unmounted is a memory leak.
Complete Example Session
The following session builds a v-click-outside directive that closes a dropdown when the user clicks outside of it, using the mounted and unmounted hooks to attach and remove the document listener.
<!-- ============================================
PART 1: THE DIRECTIVE DEFINITION
============================================ -->
<script setup>
// The directive attaches a click listener to the document
// When a click occurs outside the bound element, the callback fires
const vClickOutside = {
// mounted runs when the element is inserted into the DOM
mounted(el, binding) {
// Store the handler on the element so unmounted can remove it
// The handler checks if the click target is outside the element
el._clickOutside = (event) => {
// el.contains returns true if the target is a descendant of el
// If the target is outside, the callback is invoked
if (!el.contains(event.target)) {
binding.value()
}
}
// Attach the listener to the document
document.addEventListener('click', el._clickOutside)
},
// unmounted runs when the element is removed from the DOM
unmounted(el) {
// Remove the listener to prevent memory leaks
document.removeEventListener('click', el._clickOutside)
},
}
</script>
<!-- ============================================
PART 2: USING THE DIRECTIVE
============================================ -->
<template>
<div>
<!-- The dropdown is shown when isOpen is true -->
<div v-if="isOpen" v-click-outside="closeDropdown" class="dropdown">
<p>Dropdown content</p>
</div>
<!-- The button toggles the dropdown -->
<button @click="isOpen = !isOpen">Toggle</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const isOpen = ref(false)
function closeDropdown() {
isOpen.value = false
}
</script>
The two parts cover the directive definition with mounted and unmounted, and the usage in a component. What the session demonstrates is that the directive is a self-contained object that manages its own setup and cleanup. The mounted hook attaches the listener, the unmounted hook removes it, and the component that uses the directive does not need to know about the listener at all.
Quick Reference
Directive Hooks
| Hook | When It Runs | oldValue Available |
|---|---|---|
created | Before attributes/listeners applied | No |
beforeMount | Before element inserted | No |
mounted | After element and children mounted | No |
beforeUpdate | Before parent updates | Yes |
updated | After parent and children updated | Yes |
beforeUnmount | Before parent unmounts | No |
unmounted | After parent unmounts | No |
Hook Arguments
| Argument | Contents |
|---|---|
el | The bound DOM element |
binding.value | The value passed to the directive |
binding.oldValue | Previous value (only in beforeUpdate/updated) |
binding.arg | The argument after : |
binding.modifiers | The modifiers object |
binding.instance | The component instance |
binding.dir | The directive definition object |
vnode | The VNode of the bound element |
prevVnode | Previous VNode (only in beforeUpdate/updated) |
Vue 2 to Vue 3 Migration
| Vue 2 | Vue 3 |
|---|---|
bind | beforeMount |
inserted | mounted |
update | Removed (use updated) |
componentUpdated | updated |
unbind | unmounted |
Best Practices
✅ Do This:
// Use mounted for DOM manipulation after insertion
mounted(el) { el.focus() }
// Use unmounted for cleanup
unmounted(el) { document.removeEventListener('click', el._handler) }
// Store the handler on the element for cleanup
el._handler = () => { ... }
// Use the function shorthand when mounted and updated are the same
const vColor = (el, binding) => { el.style.color = binding.value }
❌ Don’t Do This:
// Forget to remove the listener in unmounted
mounted(el) { document.addEventListener('click', handler) } // leak
// Assume the element is in the DOM in created or beforeMount
created(el) { el.focus() } // may fail
// Access the component instance unnecessarily
mounted(el, binding) { binding.instance.someMethod() } // often a smell
// Apply a directive to a multi-root component
// The directive is ignored and a warning is logged
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Listener leak | Attached in mounted, not removed in unmounted | Remove in unmounted |
| Focus fails | Called in created or beforeMount | Use mounted |
oldValue is undefined | Read in mounted | Only available in beforeUpdate/updated |
| Directive ignored | Applied to multi-root component | Apply to a single element |
| Handler cannot be removed | Anonymous function | Store on el |
Real-World Examples
1. Focus directive
const vFocus = { mounted: (el) => el.focus() }
2. Color directive (shorthand)
const vColor = (el, binding) => { el.style.color = binding.value }
3. Click outside directive
const vClickOutside = {
mounted(el, binding) { el._h = (e) => !el.contains(e.target) && binding.value(); document.addEventListener('click', el._h) },
unmounted(el) { document.removeEventListener('click', el._h) },
}
4. Tooltip directive
const vTooltip = {
mounted(el, binding) { el.setAttribute('title', binding.value) },
updated(el, binding) { el.setAttribute('title', binding.value) },
}
5. Lazy load directive
const vLazy = {
mounted(el, binding) { /* IntersectionObserver setup */ },
unmounted(el) { /* observer disconnect */ },
}
6. Permission directive
const vPermission = {
mounted(el, binding) { if (!hasPermission(binding.value)) el.parentNode?.removeChild(el) },
}
7. Debounced input directive
const vDebounce = {
mounted(el, binding) { let t; el.addEventListener('input', () => { clearTimeout(t); t = setTimeout(binding.value, 300) }) },
}
8. Dynamic argument
<p v-pin:[direction]="200">Pinned</p>
9. Modifiers
<p v-highlight.bold.background="'yellow'">Text</p>
10. Global registration
app.directive('focus', { mounted: (el) => el.focus() })
Visual
Directive Lifecycle
┌──────────────────────────────────────────────┐
│ created │
│ → attributes/listeners not yet applied │
│ │ │
│ ▼ │
│ beforeMount │
│ → element exists, not in DOM │
│ │ │
│ ▼ │
│ mounted │
│ → element in DOM, children mounted │
│ │ │
│ ▼ │
│ beforeUpdate │
│ → parent about to update, oldValue available│
│ │ │
│ ▼ │
│ updated │
│ → parent updated, oldValue available │
│ │ │
│ ▼ │
│ beforeUnmount │
│ → parent about to unmount │
│ │ │
│ ▼ │
│ unmounted │
│ → element removed │
└──────────────────────────────────────────────┘
The binding Object
┌──────────────────────────────────────────────┐
│ v-my-directive:arg.mod1.mod2="value" │
│ │ │
│ ▼ │
│ binding = { │
│ value: <the value>, │
│ oldValue: <previous value>, │
│ arg: "arg", │
│ modifiers: { mod1: true, mod2: true }, │
│ instance: <component instance>, │
│ dir: <directive definition> │
│ } │
└──────────────────────────────────────────────┘
Function Shorthand
┌──────────────────────────────────────────────┐
│ const vColor = (el, binding) => { │
│ el.style.color = binding.value │
│ } │
│ │ │
│ ▼ │
│ Equivalent to: │
│ { │
│ mounted(el, binding) { ... }, │
│ updated(el, binding) { ... } │
│ } │
└──────────────────────────────────────────────┘
Cleanup Pattern
┌──────────────────────────────────────────────┐
│ mounted(el) { │
│ el._handler = () => { ... } │
│ document.addEventListener('click', el._handler) │
│ } │
│ │ │
│ ▼ │
│ unmounted(el) { │
│ document.removeEventListener('click', el._handler) │
│ } │
│ │
│ The handler is stored on the element │
│ so unmounted can remove the exact reference │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Directive purpose | Low-level DOM manipulation |
| Vue 3 hooks | created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted |
| Function shorthand | mounted + updated with same behavior |
el | The bound DOM element |
binding.value | Value passed to directive |
binding.arg | Argument after : |
binding.modifiers | Modifiers object |
binding.oldValue | Available in beforeUpdate/updated |
| Multi-root component | Directive ignored |
Key takeaways:
- The directive hooks mirror the component lifecycle.
created,beforeMount,mounted,beforeUpdate,updated,beforeUnmount,unmountedare the Vue 3 hooks, and their names and order are the same as the component lifecycle. The Vue 2 names (bind,inserted,update,componentUpdated,unbind) are gone . mountedis the most commonly used hook. It runs after the element and its children are in the DOM, which is when most DOM operations are safe.createdis for attaching listeners before Vue’s own listeners, andbeforeMountis for preparing the element before insertion .bindingcarries the value, argument, and modifiers.binding.valueis the expression’s value,binding.argis the argument after the colon, andbinding.modifiersis the object of flags after the dots.binding.oldValueis only available inbeforeUpdateandupdated.- The function shorthand is for
mountedandupdatedwith the same behavior. A function directive is called on both hooks, which is useful for style or attribute directives that need to re-apply on every parent update . - Cleanup belongs in
unmounted. A directive that attaches an event listener inmountedmust remove it inunmounted. The handler must be stored on the element (or in a WeakMap) so theunmountedhook has the reference to remove . - Multi-root components ignore directives. Vue 3 supports fragments, and a directive applied to a component with multiple root nodes is ignored with a warning. The directive must be applied to a single element .
Remember: Custom directives are the tool for the cases where components and composables are not enough—where the logic must touch the DOM element directly. The lifecycle hooks are the same as the component lifecycle, and the binding object carries everything the template passed. Use mounted for setup, updated for changes, and unmounted for cleanup. Do not access the component instance unless the directive genuinely needs it, and do not apply a directive to a multi-root component. The directive is a self-contained object that manages the element’s lifecycle; the component does not need to know it exists.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!