in Vue 3: the app.directive() API explained
Vue already ships the most important built-in directives, v-if, v-for and v-model among them, but some requirements cannot be covered by these because they need direct, reusable access to the raw DOM element. Detecting a click outside an element, auto-focusing an input, or initializing an element through a third-party tooltip library are classic cases for a custom directive. The app.directive() API lets you register such a directive globally and then use it in any component with a v-prefix just like a built-in directive, complete with its own lifecycle and a typed binding object.
Table of Contents
- 1. What custom directives are and when you need one
- 2. The app.directive() API: global registration in detail
- 3. A directive's lifecycle hooks: created through unmounted
- 4. The binding object: value, oldValue, arg and modifiers
- 5. Practical example: v-focus for automatic focus
- 6. Practical example: v-click-outside for dropdowns and modals
- 7. Local registration in script setup with vNameOfDirective
- 8. Directive or composable? The decision criteria
- 9. Common mistakes and conclusion
- 10. Summary
- 11. FAQ
1. What custom directives are and when you need one
A custom directive is a reusable piece of logic that gets direct access to a component's rendered DOM element and handles recurring, often imperative tasks right on that element. Unlike a component, a directive renders no markup of its own and holds no state in the classic sense, instead attaching itself as a small interceptor to an existing element's lifecycle. Typical candidates are tasks that used to be solved with direct DOM manipulation, such as setting focus, reacting to clicks outside an element, or initializing an element with an external, non-Vue library.
The decisive advantage over repeated, direct access through a template ref is reusability across many components, without copying the same onMounted code into every single one. Once registered, a directive is used in the template with a v-prefix, for example as v-focus or v-click-outside, and behaves for the component author exactly like a built-in directive. That keeps the logic maintained in one central place, while every component that needs it only adds a single line to its template.
2. The app.directive() API: global registration in detail
Global registration happens through app.directive(name, definition), called on the app instance also used for app.component() or app.use(). The name is passed without a v-prefix, so app.directive('focus', ...) is later used as v-focus in the template. The definition can either be an object with individual lifecycle hooks, or, as a shorthand, a single function that is then called for both mounted and updated, provided both hooks are meant to behave identically.
Globally registered directives are then available throughout the entire application, regardless of which component uses them, which makes them well suited for generic, project-wide tools like v-focus or v-tooltip. For very specific directives only needed in a narrowly scoped part of the application, local registration directly inside the component, covered later in this article, is the better fit, since it avoids the risk of collisions in the global namespace.
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// Shorthand: one function for mounted and updated at once
app.directive('highlight', (el, binding) => {
el.style.backgroundColor = binding.value ?? 'yellow'
})
app.mount('#app')
3. A directive's lifecycle hooks: created through unmounted
A complete directive definition knows seven hooks tied closely to the lifecycle of the component in which the element is rendered: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount and unmounted. created runs before the element's attributes are applied, beforeMount right before the element is first inserted into the DOM, and mounted right after, once the element and all child components are fully mounted. For most practical cases, such as setting focus or registering an event listener, mounted is the right hook, because the element is guaranteed to exist in the real DOM there.
beforeUpdate and updated fire whenever the host vnode re-renders, regardless of whether the directive's own value actually changed, which is why comparing binding.value against binding.oldValue inside these hooks is often necessary to avoid unnecessary work. beforeUnmount and unmounted finally run shortly before and after the element is removed from the DOM respectively, and are the right place to remove event listeners that were registered in mounted, so no memory leaks accumulate.
import type { Directive } from 'vue'
const vFocus: Directive<HTMLElement, void> = {
mounted(el) {
el.focus()
},
}
export default vFocus
4. The binding object: value, oldValue, arg and modifiers
Every hook of a directive receives a binding object as its second argument, describing the full context of how the directive is used in the template. value holds the current value passed to the directive through v-mydirective="someValue", while oldValue in the update hooks provides the value from the previous render, enabling targeted comparisons. arg reads an optional argument after a colon, such as the color in v-mydirective:color="'red'", and modifiers is an object with boolean flags for every dot-appended modifier, such as once in v-mydirective.once.
In addition, binding.instance gives access to the component instance in which the directive is used, useful in rare cases where the directive needs to react to component properties that were not explicitly passed as value. In TypeScript projects it pays off to declare the directive with the generic type Directive
5. Practical example: v-focus for automatic focus
The classic beginner example for a custom directive is v-focus, which automatically focuses an input element once mounted, for instance a search field that should be ready for input right after a modal opens. Without a directive, every component that needs this behavior would have to create its own template ref and manually call el.value?.focus() in onMounted, which quickly leads to duplicated code across several forms in a project. As a directive, that collapses into a single v-focus in the template, regardless of how often and where the functionality is needed.
In practice it is worth tying v-focus to an optional condition, such as v-focus="shouldFocus", so focus is only set once a particular state is actually reached, for example a freshly opened modal. The hook then checks binding.value at the start of mounted and only calls el.focus() on a truthy result, which keeps the same directive reusable for both unconditional and conditional autofocus without maintaining two separate directives.
6. Practical example: v-click-outside for dropdowns and modals
A much more common use case in real projects is v-click-outside, which automatically closes a dropdown, a context menu, or a modal as soon as a click occurs outside the element. The implementation registers a global click listener on document inside mounted, checking whether the click target lies inside the element and, if not, calling the provided callback function. It is crucial to remove that listener again in unmounted, otherwise every time the dropdown opens and closes a new, never cleaned up listener stays attached to document, gradually slowing the application down over time.
To keep the listener consistently referenced between mounted and unmounted, the handler function is typically stored directly on the element itself, for instance under a custom property like el._clickOutsideHandler, rather than in an external variable living outside the directive. This technique works reliably even when the same directive is used on multiple elements in different components at once, because each element carries its own handler reference and the instances do not overwrite each other.
import type { Directive } from 'vue'
type ClickOutsideBinding = (event: MouseEvent) => void
const vClickOutside: Directive<HTMLElement, ClickOutsideBinding> = {
mounted(el, binding) {
const handler = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value(event)
}
}
el._clickOutsideHandler = handler
document.addEventListener('click', handler, true)
},
unmounted(el) {
document.removeEventListener('click', el._clickOutsideHandler, true)
delete el._clickOutsideHandler
},
}
export default vClickOutside
7. Local registration in script setup with vNameOfDirective
Besides global registration via app.directive(), Vue also supports local directives that are only valid within a single component. In the classic Options API style this happens through the directives option, while in script setup it happens automatically through a naming convention: a constant whose name starts with a lowercase v followed by PascalCase, such as vClickOutside, is automatically recognized by the compiler as v-click-outside in the template, with no explicit registration required.
This convention is especially handy for directives only needed within a narrowly scoped feature area, such as a special validation directive used exclusively in a single form. It also avoids the risk of naming collisions in the global namespace that can arise from registering many directives through app.directive() in a large project, since every component imports its local directives independently and only exposes them where they are actually needed.
8. Directive or composable? The decision criteria
The central question when choosing between a directive and a composable is whether the logic needs direct, imperative access to a raw DOM element, or whether it can be expressed entirely through reactive state and template bindings. A composable like useMousePosition or useLocalStorage encapsulates reactive logic and returns reactive values that then land in the template through ordinary interpolation or v-bind, with no code ever having to touch the DOM element itself.
A directive, on the other hand, is the right choice as soon as the task is inseparably tied to an imperative DOM operation, such as setting focus, registering a global event listener on a specific element, or binding an external, non-Vue library like a chart or tooltip library directly to an element. Some cases can even be solved with either approach, such as click-outside detection, which can also be implemented as a composable taking a template ref as a parameter. Rule of thumb: if more than one component needs the same behavior on multiple, arbitrary elements in the template, a directive is usually the more concise, declarative solution.
9. Common mistakes and conclusion
The most common mistake is failing to remove event listeners or timers registered in mounted inside the matching unmount hook, letting listener corpses accumulate with every mount and unmount cycle of the component, noticeably costing memory and performance. A second common mistake is reacting blindly to every change inside updated without comparing binding.value against binding.oldValue, which causes unnecessary work on expensive operations such as re-initializing an external library on every render, even when the relevant value has not actually changed.
The takeaway: custom directives are not a replacement for composables, but a complementary tool for exactly the cases where imperative DOM access is unavoidable. Using app.directive() for project-wide, generic directives like v-focus and reserving local vNameOfDirective constants for feature-specific cases keeps a clean separation between globally reusable and locally scoped behavior, without directive logic sprawling uncontrollably across the whole project.
| Hook | Timing | Typical use | DOM access |
|---|---|---|---|
| created | Before attributes are applied | Initial setup without DOM access | No |
| mounted | Element fully attached to the DOM | Setting focus, registering listeners | Yes, fully |
| updated | After every re-render of the host vnode | Value comparison, targeted updates | Yes, fully |
| unmounted | After the element is removed from the DOM | Cleaning up listeners and timers | Element already removed |
Mironsoft
Vue architecture, Composition API, and Nuxt performance
Vue applications that don't get more complicated with every feature?
We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.
Architecture Review
Checking composables, state management, and component structure for maintainability.
Performance Audit
Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.
Nuxt Integration
Building robust, type-safe SSR/SSG setup and API integration.
10. Summary
Custom Directives in Vue: The Essentials at a Glance
Registration
Global via app.directive(name, definition), local in script setup automatically via constants following the vNameOfDirective scheme.
Lifecycle
Seven hooks from created to unmounted, tied closely to the lifecycle of the host component.
Context
The binding object provides value, oldValue, arg and modifiers on every hook call.
Decision
Directive for imperative DOM access, composable for reactive logic without direct DOM handling.