03 · Custom Directives & Plugins¶
Components and composables cover almost everything. Two extension points remain for the
rest: custom directives, for reusable low-level behaviour attached to a DOM element,
and plugins, for installing app-wide functionality in one app.use() call. This lesson
builds two directives and a toast-notification plugin, all tested.
When a directive is the right tool¶
A custom directive gets direct access to an element and hooks into its lifecycle. Use one when the behaviour is about the element itself and would be awkward as a component: focusing, measuring, observing intersection or resize, detecting clicks outside, adding a third-party behaviour (a tooltip library, an input mask) to an existing element.
If the behaviour needs its own markup or state that renders, it's a component. If it's logic without an element, it's a composable.
Directive hooks¶
A directive is an object with optional hooks, each called with (el, binding, vnode,
prevVnode):
| Hook | Called |
|---|---|
created |
before the element's attributes and listeners are applied |
beforeMount |
before the element is inserted |
mounted |
after the element and its children are in the DOM |
beforeUpdate / updated |
before / after the containing component updates |
beforeUnmount / unmounted |
before / after the element is removed |
binding contains value (the expression's result), oldValue (in update hooks), arg
(v-my:arg), modifiers (v-my.a.b → { a: true, b: true }) and instance.
Example 1: v-autofocus¶
import type { Directive } from 'vue'
/** v-autofocus — focus on mount; v-autofocus="false" to disable; v-autofocus.select to also select text */
export const vAutofocus: Directive<HTMLInputElement | HTMLTextAreaElement, boolean | undefined, 'select'> = {
mounted(el, { value, modifiers }) {
if (value === false) return
el.focus()
if (modifiers.select) el.select()
},
}
The type parameters of Directive are the element type, the value type and the allowed
modifiers, so v-autofocus.selct (typo) is a type error in templates checked by
vue-tsc.
Why not the HTML autofocus attribute? It only works on page load. Elements that appear
later — a field inside a modal or an inline editor — need programmatic focus when they
mount. In our test the input was focused on mount and, with .select, its text was
selected: focused: true selection: 0 5.
Example 2: v-click-outside¶
Closing a menu or popover when the user clicks elsewhere is a classic directive:
import type { Directive } from 'vue'
type Handler = (event: PointerEvent) => void
interface State {
handler: Handler // latest bound function
listener: (e: Event) => void
}
const state = new WeakMap<HTMLElement, State>()
export const vClickOutside: Directive<HTMLElement, Handler> = {
mounted(el, binding) {
const s: State = {
handler: binding.value,
listener: (e) => {
if (!el.contains(e.target as Node)) s.handler(e as PointerEvent)
},
}
state.set(el, s)
// capture phase: still fires if something inside the page stops propagation
document.addEventListener('pointerdown', s.listener, true)
},
updated(el, binding) {
// inline handlers are new functions on every render; keep the latest one
const s = state.get(el)
if (s) s.handler = binding.value
},
unmounted(el) {
const s = state.get(el)
if (s) document.removeEventListener('pointerdown', s.listener, true)
state.delete(el)
},
}
Design points:
- Per-element state lives in a
WeakMapkeyed by the element, so it's garbage collected with the element and doesn't pollute the DOM node with custom properties. - The listener is registered once and always calls the latest handler. Templates
often pass inline arrow functions (
v-click-outside="() => open = false"), which are new functions on every render; theupdatedhook swaps in the new one. Our first version removed and re-added the document listener inupdated— it worked, but did DOM work on every render of the parent for no reason. pointerdownin the capture phase fires before any click handler inside the page can callstopPropagation(), and covers mouse, touch and pen.unmountedremoves the listener — forgetting that leaks one listener per mount.
Usage in <script setup>: any imported variable whose name starts with v and is
camelCase can be used as a directive directly:
<script setup lang="ts">
import { ref } from 'vue'
import { vClickOutside } from '@/directives/clickOutside'
const open = ref(false)
</script>
<template>
<div class="dropdown">
<button type="button" :aria-expanded="open" @click="open = !open">Options</button>
<ul v-if="open" v-click-outside="() => (open = false)" role="menu">
<li role="menuitem">Rename</li>
<li role="menuitem">Delete</li>
</ul>
</div>
</template>
In our test, a pointerdown inside the menu left it open; one on a button outside closed
it and the menu was removed from the DOM:
(For a real dropdown menu, also close on Escape and manage focus — Lesson 08 covers that.)
To register a directive globally instead: app.directive('click-outside', vClickOutside).
Directives on components¶
A directive on a component applies to the component's root element. If the component has several root nodes, Vue warns and ignores it. Prefer putting directives on elements.
SSR¶
Directive hooks don't run during server rendering (there's no DOM). If a directive must
affect the server HTML — say, adding an attribute — implement the getSSRProps hook.
Plugins¶
A plugin is an object with an install(app, options) method, or a function with the same
signature. Inside install you can do anything you'd otherwise do in main.ts:
app.component()— register global components;app.directive()— register global directives;app.provide()— make a service injectable everywhere;app.config.globalProperties.x = ...— add$xto every component instance (for templates and the Options API);app.mixin()— possible, but avoid in new code.
Vue Router and Pinia are plugins; so are most UI libraries.
Example: a toast plugin¶
import { reactive, readonly, inject, type App, type InjectionKey } from 'vue'
import ToastHost from './ToastHost.vue'
export interface Toast { id: number; message: string; kind: 'info' | 'success' | 'error' }
export interface ToastApi {
toasts: readonly Toast[]
show: (message: string, kind?: Toast['kind'], ms?: number) => void
dismiss: (id: number) => void
}
export const ToastKey: InjectionKey<ToastApi> = Symbol('toast')
export function createToast(options: { duration?: number } = {}) {
const duration = options.duration ?? 4000
const toasts = reactive<Toast[]>([])
let nextId = 1
const api: ToastApi = {
toasts: readonly(toasts) as readonly Toast[],
show(message, kind = 'info', ms = duration) {
const id = nextId++
toasts.push({ id, message, kind })
if (ms > 0) setTimeout(() => api.dismiss(id), ms)
},
dismiss(id) {
const i = toasts.findIndex((t) => t.id === id)
if (i !== -1) toasts.splice(i, 1)
},
}
return {
install(app: App) {
app.provide(ToastKey, api)
app.component('ToastHost', ToastHost)
app.config.globalProperties.$toast = api
},
}
}
export function useToast(): ToastApi {
const api = inject(ToastKey)
if (!api) throw new Error('useToast() requires app.use(createToast())')
return api
}
declare module 'vue' {
interface ComponentCustomProperties {
$toast: ToastApi
}
interface GlobalComponents {
ToastHost: typeof ToastHost
}
}
<script setup lang="ts">
import { useToast } from './index'
const toast = useToast()
</script>
<template>
<div class="toast-host" role="status" aria-live="polite">
<TransitionGroup name="toast">
<p v-for="t in toast.toasts" :key="t.id" class="toast" :class="t.kind">
{{ t.message }}
<button type="button" aria-label="Dismiss" @click="toast.dismiss(t.id)">×</button>
</p>
</TransitionGroup>
</div>
</template>
<style scoped>
.toast-host { position: fixed; bottom: 1rem; right: 1rem; display: grid; gap: 0.5rem; }
.toast { margin: 0; padding: 0.5rem 0.75rem; border-radius: 6px; background: #1f2937; color: #fff; }
.toast.error { background: #b91c1c; }
.toast.success { background: #15803d; }
.toast-enter-active, .toast-leave-active { transition: opacity 150ms; }
.toast-enter-from, .toast-leave-to { opacity: 0; }
</style>
Install and use it:
import { createToast } from './plugins/toast'
app.use(createToast({ duration: 3000 }))
Notes on the design:
- A factory (
createToast(options)) returns the plugin, the same pattern ascreatePinia()andcreateRouter(). Each app gets its own state — important for SSR and tests. - The API is provided with a typed key and consumed with a
useToast()composable — the Composition API way.$toastinglobalPropertiesis there for templates and Options API components; thedeclare module 'vue'block types both it and the global<ToastHost>component. - The host uses
role="status"andaria-live="polite", so screen readers announce new toasts without interrupting.
In our test, one toast created through useToast() and one through $toast, then after the
duration elapsed (fake timers):
How It Actually Works¶
Directives are compiled into the render function as withDirectives(vnode, [[dir,
value, arg, modifiers]]). That helper stores the directive bindings on the vnode
(vnode.dirs). The renderer calls each hook at the matching point of its own element
lifecycle — invokeDirectiveHook(vnode, prevVNode, instance, 'mounted') right after
inserting the element, beforeUpdate/updated around patching it, and so on. The
updated hook runs whenever the containing component re-renders, even if the
directive's value didn't change — which is why the click-outside directive keeps that hook
cheap. Built-ins such as v-show and v-model are implemented exactly the same way.
app.use(plugin, options) checks that the plugin hasn't been installed on this app
already (Vue keeps a Set of installed plugins and warns on a second install), then calls
plugin.install(app, options), or plugin(app, options) if it's a function. There's no
further magic. app.provide writes into the app context's provides object, which is the
root of the prototype chain every component's inject walks (Level 2 · 02).
globalProperties are consulted by the component proxy's get trap as a last resort,
after setup state, data, props and $ built-ins.
Common mistakes¶
- Using a directive where a component or composable fits better — directives can't render markup and are harder to test in isolation.
- Forgetting cleanup in
unmounted— listeners, observers and third-party instances leak. - Reading
binding.valueonly inmounted— if the value can change, handleupdatedtoo. - Mutating the element in ways Vue will overwrite — setting
el.classNamein a directive fights:classbindings. UseclassList.add/remove. - Singleton plugin state at module level — shared across SSR requests and across test apps. Create state inside the factory.
- Relying on
globalPropertiesin<script setup>— they're not in scope there. Use provide/inject.
Exercise¶
- Write
v-intersectthat calls a handler when the element enters the viewport, usingIntersectionObserver, with a.oncemodifier. Use it to lazy-load images. - Add Escape-to-close to
v-click-outsideas an option (v-click-outside="{ handler, escape: true }"), keeping the type definitions accurate. - Extend the toast plugin with a
promise(p, { loading, success, error })method that shows a loading toast and replaces it with the outcome. - Write a Vitest test that installs the plugin twice on the same app and assert on the
warning Vue prints. Then test
useToast()outside an app and assert on your error.