Hook — 05 : 42 a.m., São Paulo ↔ Punta Cana bug sprint
A blackout hit the cowork in Punta Cana just as Luis in São Paulo discovered that our tooltip library had ballooned to 90 kB. “We only need one tiny fade-in!” he pleaded over a flickering video call. Candlelight on my Dominican balcony, I reached for Vue’s directive API, wrote a six-linev-fade
that animated opacity on mount and unmount, and tree-shook the heavy dependency out. Hot-reload proved the fix before the generator hummed back to life. That late-night rescue is the perfect stage for learning when and how to build your own directives.
Why Directives Are Still Relevant
Frameworks change, but DOM realities remain: focus traps, intersection observers, drag-and-drop hooks, or third-party widgets often need low-level access to elements. Components handle structure; directives let you sprinkle behavior onto arbitrary HTML without bloating your template with utility classes or JS inlined inside setup()
. Vue’s directive system is tiny, tree-shakable, and type-safe—ideal for features that should feel like native template syntax.
Core Idea in Plain English
A custom directive is a lifecycle object that hooks into an element’s creation, update, and teardown. It can read modifiers and arguments (v-fade:in.duration.300
), manipulate the DOM, and interact with component state—all while keeping markup declarative.
Anatomy of a Simple Directive
tsCopyEdit// directives/fade.ts
import { Directive } from 'vue';
export const vFade: Directive<HTMLElement, number> = {
mounted(el, binding) {
const ms = binding.value ?? 300;
el.style.transition = `opacity ${ms}ms`;
el.style.opacity = '0';
requestAnimationFrame(() => (el.style.opacity = '1'));
},
beforeUnmount(el) {
el.style.opacity = '0';
},
};
Register globally in your entry:
tsCopyEditcreateApp(App).directive('fade', vFade).mount('#app');
Template usage:
htmlCopyEdit<p v-fade="500">Smooth arrival text</p>
No additional JavaScript in the component file.
When to Reach for a Directive
- Cross-cutting DOM Behaviors – tooltips, scroll-into-view, lazy images.
- Low-level Element Control – content-editable, autofocus, outside-click detection.
- Reuse on Native Elements – behaviors that shouldn’t require wrapper components.
- Library Replacement – swap kilobytes of JS for a few lines of custom logic.
If the feature renders its own markup or holds state, consider a component instead.
Step-by-Step: Building a v-click-outside
Guard
Directive Code
tsCopyEdit// directives/clickOutside.ts
import { Directive } from 'vue';
export const vClickOutside: Directive<HTMLElement, () => void> = {
mounted(el, binding) {
const handler = (e: Event) => {
if (!el.contains(e.target as Node)) binding.value?.();
};
el.__vueClickOutside__ = handler; // stash reference
document.addEventListener('click', handler);
},
unmounted(el) {
document.removeEventListener('click', el.__vueClickOutside__);
},
} as unknown as Directive;
Usage in a Dropdown
vueCopyEdit<script setup>
import { ref } from 'vue';
const open = ref(false);
const close = () => (open.value = false);
</script>
<template>
<button @click="open = !open">Toggle</button>
<ul v-if="open" v-click-outside="close" class="menu">
<slot />
</ul>
</template>
Now every dropdown in the app closes when users click elsewhere—no duplicated event listeners.
Remote-Work Insight Box
During a Medellín hackathon, three teams implemented their own modal close logic. Merge conflicts stacked up. We replaced dozens of watchers with one v-click-outside
directive, merged in minutes, and spent the saved time scouting empanada spots instead of resolving Git churn.
Directive Hooks Cheat Sheet
Hook | Fires | Typical Use |
---|---|---|
created | Element is attached to DOM but not yet inserted | Initialize but avoid heavy DOM reads |
mounted | Element inserted, parent component mounted | Add listeners, measure size |
updated | Bound value or VNode changed | React to prop updates |
beforeUnmount | About to disappear | Cleanup timers/listeners |
unmounted | Removed from DOM | Safeguard final cleanup |
Performance & Accessibility Checkpoints
- Memory – store handler references on the element, remove in
unmounted
. - Event Delegation – prefer single throttled listener on document when many elements share the directive.
- ARIA – update attributes (
aria-expanded
,aria-live
) inside directive to keep components simple. - SSR – execute only in browser; guard with
if (typeof window !== 'undefined')
.
Common Pitfalls and Swift Fixes
Symptom | Hidden Cause | Remedy |
---|---|---|
Directive runs twice in dev | Vue’s HMR recreates VNodes | Wrap side-effects in if (import.meta.hot) checks or debounce |
TypeScript complains about custom prop | Generic type missing | Directive<El, Value> generic clarifies binding.value |
Element styles reset unexpectedly | External CSS overrides directive inline styles | Use CSS variables or add !important sparingly |
Memory leak after route change | Forgot to remove global listener | Always detach in unmounted |
defineDirective
as a Composable Helper
tsCopyEdit// composables/useTooltip.ts
import { watchEffect } from 'vue';
export function useTooltip(content: string) {
return {
mounted(el: HTMLElement) {
el.title = content;
},
updated(el: HTMLElement, { value }) {
el.title = value;
},
};
}
Consumers call:
vueCopyEdit<div v-bind="useTooltip('Hola desde Panamá')"></div>
Composable directives enable dynamic behavior where content or config comes from reactive sources.
CLI and Tooling Nuggets
Command | Description |
---|---|
npm create vite@latest vue-directive-lab --template vue-ts | Scaffolds playground |
vue-tsc --noEmit -w | Type-check custom directive generics in watch mode |
vite build --report | Validate that heavy libraries were removed after directive replacement |
Diagram Idea (textual)
Flow: Template with v-click-outside
→ Compiler identifies directive → Runtime creates proxy object → mounted
attaches listener → User click outside → callback triggers → beforeUnmount
removes listener on component destroy.
Wrap-Up
Custom directives are Vue’s unsung heroes: they keep templates expressive, replace heavy libraries, and encapsulate low-level DOM dance moves. Reach for them when behavior transcends component boundaries, document cleanup diligently, and your app will stay lean—even on shaky hotel Wi-Fi in Costa Rica.
Got directive adventures or pitfalls? Share below; I’ll reply between flights and late-night arepa sessions.