Directive or Component

A directive adds behaviour to an element you already wrote; a component owns markup. One question settles nearly every case.

Syntaxupdated(el, binding) { if (binding.value === binding.oldValue) return }

Three tools overlap here: a component, a custom directive, and a composable. Choosing wrongly is not fatal, but it shows up later as awkward markup, or as props that exist only to pass values through to an element you have hidden.

The question

Does the thing you are building decide what HTML exists? If it renders anything, needs slots, or owns state the user sees, it is a component. If it only acts on an element someone else already wrote, it is a directive. A directive receives the element and brings no markup of its own; that is the whole trade.

Autofocus, click-outside, an IntersectionObserver, an input mask from a third-party library: element behaviour, so directives. A modal, a data table, a field with its label and error message: markup, so components.

Where a composable fits

A composable is a plain function that uses Vue's reactivity and is called from <script setup>. Paired with a template ref it does what a directive does, with full types and a visible call site. Prefer it when there is one element in one component. Prefer a directive when the behaviour has to be applied from markup — to many elements, inside a loop, or by other people's templates. A directive is the only one of the three that can be dropped onto an arbitrary element from outside.

What trips people up

updated runs on every re-render of the host component, not only when the bound value changed. On a component tag, a directive applies to that component's root element, and is ignored with a warning if the component has more than one root node. And directive hooks do not run during server rendering unless the directive defines getSSRProps.

The example is a note field: one line tall to start, growing as the text wraps. The growth is a directive, so the textarea keeps its own id, name and v-model.

Example

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import type { Directive } from 'vue'

function fit(el: HTMLTextAreaElement) {
  el.style.height = 'auto'
  el.style.height = `${el.scrollHeight}px`
}

const vAutosize: Directive<HTMLTextAreaElement, string> = {
  mounted(el) {
    fit(el)
  },
  updated(el, binding) {
    if (binding.value === binding.oldValue) return
    fit(el)
  }
}

const note = ref('')
</script>

<template>
  <label for="note">Note</label>
  <textarea id="note" v-model="note" v-autosize="note" rows="1" />
</template>

<style scoped>
textarea {
  resize: none;
  overflow: hidden;
}
</style>

When to use it

  • Deciding whether "autosize this textarea" should be a directive, a component or a composable.
  • Replacing a wrapper component that exists only to forward every attribute to a single input.
  • Packaging behaviour so other teams can apply it to elements in their own templates.
  • Working out why a directive placed on a component tag does nothing.

More examples

The same feature as a component, and what it costs

This works, and it is the wrong shape for this feature. The component now owns the textarea, so the two-way binding has to be re-plumbed with defineModel (stable since Vue 3.4), and every attribute a caller might need - id, name, placeholder, aria-describedby, rows - either falls through or has to be declared. Compare that with the directive version, where the caller still writes a plain textarea. Choose the component when you have something to add to the markup: a character counter, a label, an error line. useTemplateRef arrived in Vue 3.5; before that the same job used a ref named the same as the ref attribute.

Example · vue
<script setup lang="ts">
// AutosizeTextarea.vue
import { nextTick, watch, useTemplateRef } from 'vue'

const model = defineModel<string>({ required: true })
const field = useTemplateRef<HTMLTextAreaElement>('field')

watch(model, async () => {
  await nextTick()
  const el = field.value
  if (!el) return
  el.style.height = 'auto'
  el.style.height = `${el.scrollHeight}px`
})
</script>

<template>
  <textarea ref="field" v-model="model" rows="1" />
</template>

<style scoped>
textarea {
  resize: none;
  overflow: hidden;
}
</style>

The composable version

Called from a component as useAutosize(useTemplateRef('field'), note). This is the version to reach for when there is one call site: the dependency is visible in the script, the types are checked, and it can be unit tested by handing it a ref. flush: 'post' matters - the default 'pre' flush runs the callback before Vue patches the DOM, so scrollHeight would be measured against the old text. What a composable cannot do is be applied from a template, which is exactly when a directive wins.

Example · ts
// src/composables/useAutosize.ts
import { onMounted, watch } from 'vue'
import type { Ref, ShallowRef } from 'vue'

export function useAutosize(
  el: Readonly<ShallowRef<HTMLTextAreaElement | null>>,
  text: Ref<string>
): void {
  function fit() {
    const node = el.value
    if (!node) return
    node.style.height = 'auto'
    node.style.height = `${node.scrollHeight}px`
  }

  onMounted(fit)
  watch(text, fit, { flush: 'post' })
}

A directive on a component tag

Worth recognising, worth avoiding. A directive on a component is forwarded to that component's root node, so this code depends on a private detail of CardPanel: the day its template gains a second root node, Vue cannot tell which element was meant, drops the directive and logs a warning. The Vue documentation calls this usage not recommended. If a component needs to expose behaviour, give it a prop or a slot, or put the directive on an element inside its own template.

Example · vue
<script setup lang="ts">
import type { Directive } from 'vue'
import CardPanel from './CardPanel.vue'

const vOutline: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    el.style.outline = binding.value
  }
}
</script>

<template>
  <!-- Applies to CardPanel's single root element. -->
  <card-panel v-outline="'2px solid currentColor'" />
</template>

Discussion

  • Be the first to comment on this lesson.