| |

Vue.js 62 🟢 Writing Custom Directives (Lifecycle Hooks: created, beforeMount, mounted, updated)

⏱️ Estimated read: 15 min | |

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

HookWhen It RunsoldValue Available
createdBefore attributes/listeners appliedNo
beforeMountBefore element insertedNo
mountedAfter element and children mountedNo
beforeUpdateBefore parent updatesYes
updatedAfter parent and children updatedYes
beforeUnmountBefore parent unmountsNo
unmountedAfter parent unmountsNo

Hook Arguments

ArgumentContents
elThe bound DOM element
binding.valueThe value passed to the directive
binding.oldValuePrevious value (only in beforeUpdate/updated)
binding.argThe argument after :
binding.modifiersThe modifiers object
binding.instanceThe component instance
binding.dirThe directive definition object
vnodeThe VNode of the bound element
prevVnodePrevious VNode (only in beforeUpdate/updated)

Vue 2 to Vue 3 Migration

Vue 2Vue 3
bindbeforeMount
insertedmounted
updateRemoved (use updated)
componentUpdatedupdated
unbindunmounted

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

PitfallWhy It HappensFix
Listener leakAttached in mounted, not removed in unmountedRemove in unmounted
Focus failsCalled in created or beforeMountUse mounted
oldValue is undefinedRead in mountedOnly available in beforeUpdate/updated
Directive ignoredApplied to multi-root componentApply to a single element
Handler cannot be removedAnonymous functionStore 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

ItemValue
Directive purposeLow-level DOM manipulation
Vue 3 hookscreated, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted
Function shorthandmounted + updated with same behavior
elThe bound DOM element
binding.valueValue passed to directive
binding.argArgument after :
binding.modifiersModifiers object
binding.oldValueAvailable in beforeUpdate/updated
Multi-root componentDirective ignored

Key takeaways:

  • The directive hooks mirror the component lifecycle. created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted are 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 .
  • mounted is 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. created is for attaching listeners before Vue’s own listeners, and beforeMount is for preparing the element before insertion .
  • binding carries the value, argument, and modifiers. binding.value is the expression’s value, binding.arg is the argument after the colon, and binding.modifiers is the object of flags after the dots. binding.oldValue is only available in beforeUpdate and updated .
  • The function shorthand is for mounted and updated with 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 in mounted must remove it in unmounted. The handler must be stored on the element (or in a WeakMap) so the unmounted hook 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.


Was this helpful?


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!