skip to content

When should a Vue 3 codebase implement a behaviour as a custom directive rather than a component, a composable or a built-in binding?

level: seniorimportance: should knowfreq 38%

answer

  1. only direct DOM manipulation
  2. no markup, no own state
  3. built-ins are SSR-friendly
  4. works on any plain element
  5. composables share stateful logic

basics

~20 s

Use a custom directive only when the behaviour needs direct DOM manipulation on a plain element, such as focusing or detecting outside clicks. Use a component when it renders markup, a composable for stateful logic, and built-in bindings whenever they can express it.

solid answer

~40 s

The Vue guide says custom directives should only be used when the functionality can only be achieved through **direct DOM manipulation**, and that built-in directives such as `v-bind` are preferred when possible because they are more efficient and server-rendering friendly. So: if the behaviour renders markup, it is a **component**; if it is reusable stateful logic, such as tracking the mouse or fetching data, it is a **composable**; if it is a class, style or attribute that depends on state, it is a **binding**. A directive fits imperative, per-element work that should attach to *any* element without wrapping it: `v-focus`, `v-click-outside`, integrating a small DOM library. Its limits are real: no template, no own reactive state, hooks that do not run during server rendering, and awkward behaviour on components.

code

vue · 23 lines
vue
<script setup>
import { ref } from 'vue'

const active = ref(false)

// directive only where DOM access is unavoidable
const vSelectOnFocus = {
  mounted(el) {
    el.addEventListener('focus', el.select)
  },
  unmounted(el) {
    el.removeEventListener('focus', el.select)
  }
}
</script>

<template>
  <!-- declarative state: a binding, not a directive -->
  <li :class="{ highlight: active }" @click="active = !active">Row</li>

  <!-- imperative DOM call: a directive -->
  <input v-select-on-focus value="copy me" />
</template>

go deeper

for a junior

Know that directives are for direct DOM work, while components render markup.

for a middle

Explain why built-in bindings are preferred and how composables differ from directives.

for a senior

Argue a choice for a real feature, weighing SSR output, discoverability and cleanup cost.

for a principal

Define team guidelines on when new shared directives are justified and how they are reviewed and tested.

## Four tools, four jobs Vue 3 offers several ways to reuse behaviour. Choosing well starts with what the behaviour produces: | Tool | Produces | Typical example | |---|---|---| | Built-in binding (`:class`, `:style`, `v-show`) | declarative DOM state from data | highlight a row when selected | | Component | markup plus behaviour | a tooltip that renders its own bubble | | Composable | reactive state and logic, no markup | `useMousePosition()`, `useFetch()` | | Custom directive | imperative work on an existing element | `v-focus`, `v-click-outside` | The Vue guide draws the line explicitly: components are the main building blocks, composables reuse stateful logic, and custom directives are mainly for **low-level DOM access on plain elements**. It adds that directives should only be used when the functionality can **only** be achieved via direct DOM manipulation. ## Prefer declarative bindings first Many first-draft directives duplicate what a binding does better. A `v-highlight` that adds a class in `mounted` and removes it in `updated` is just `:class="{ highlight: isActive }"`. The guide recommends built-in directives because they are **more efficient**, since the compiler optimises them, and **server-rendering friendly**, since their output appears in the server HTML. ## Where directives are the right tool - **Focus, selection and scroll:** `el.focus()`, `el.select()`, `scrollIntoView()` have no declarative equivalent. - **Global DOM events tied to one element:** click-outside, resize observation of an element. - **Wrapping a small imperative library** that needs an element and a teardown. - **Behaviour that must attach to any element** without wrapping it in a component: a directive can sit on an `<input>`, `<div>` or `<button>` equally. ## Limits of directives 1. **No markup.** A directive cannot render a tooltip bubble; it can only manipulate what exists, so rich UI belongs in a component (often with `<Teleport>` for overlays). 2. **No own reactive state.** State lives in `el.dataset`, a `WeakMap` or the owner's state, not in the directive. 3. **Client-only hooks.** Directive hooks do not run during server rendering. A directive object can supply `getSSRProps` to contribute attributes to server output, but most custom directives simply do nothing on the server, which can cause visible differences after hydration. 4. **Poor fit on components.** On a component the directive lands on the root element, and on a multi-root component it does not work as intended. 5. **Implicit inputs.** Arguments, modifiers and values are less discoverable than a component's props. ## A decision sequence 1. Can a built-in binding express it? Use the binding. 2. Does it render markup? Build a component. 3. Is it reusable logic returning state? Write a composable. 4. Is it imperative work on an element that many templates need? Write a directive, with cleanup in `unmounted`. A composable and a directive can also cooperate: a composable such as `useClickOutside(targetRef, handler)` works with template refs inside one component, while a directive packages the same logic for use on arbitrary elements across many templates. ## How interviewers probe this They often propose a feature, such as tooltips, lazy images or permissions, and ask which tool you would use. Good answers name what the behaviour produces, mention SSR, and admit the trade-off: a `v-permission` directive that removes elements from the DOM is imperative and invisible to server output, whereas `v-if="can('edit')"` keeps the rule in the template. ## Worked examples | Feature | Choice | Reason | |---|---|---| | Autofocus a field when a dialog opens | directive (`v-focus`) | imperative `focus()`, many templates | | Lazy-load images when visible | directive or component | directive if it only swaps `src` via an observer; component if it renders placeholders | | Tooltip with a styled bubble | component | it renders markup | | Track window size | composable | reactive state, no element | | Mark the active nav link | binding | `:class` from route state | The lazy-image row shows that the line is not always sharp: the deciding question is whether the behaviour **produces markup** or only **acts on an element that already exists**.

  • Why is a Vue 3 `v-permission` directive that removes elements in `mounted` weaker than `v-if` with a permission check?
    The directive runs only in the browser after mount, so the element is rendered first, appears in server HTML, and disappears later. It also hides the rule from the template. `v-if="can('edit')"` never renders the element, works in server rendering, and reacts to permission changes declaratively.
  • What does the `getSSRProps` hook of a Vue 3 directive object do?
    During server rendering, directive hooks such as `mounted` do not run. `getSSRProps(binding, vnode)` lets a directive return attributes to merge into the server-rendered element, which is how a built-in like `v-show` renders its `display` style on the server. Most custom directives do not define it.

saying these in an interview costs you the question

  • Builds a directive to toggle a class that :class could handle
  • Tries to render a tooltip bubble from a directive
  • Assumes directive hooks run during server rendering
  • Treats directives and composables as interchangeable
  • Hides authorisation rules in a DOM-removing directive