Skip to main content

⚑ Vue Directives and Event Handling

Directives are how you wire your data into the DOM, and event handling is how user actions flow back into your data. Together they turn a static template into a living, interactive interface. This lesson covers the core directives, two-way binding, and how components talk to their parents.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Read the anatomy of a directive β€” name, argument, modifier, and expression
  • Use the core directives: v-bind, v-if/v-else, v-show, v-for, and v-model
  • Choose correctly between v-if and v-show
  • Handle events with v-on and streamline them with event modifiers
  • Emit and listen for custom component events, and write a basic custom directive

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a validated contact form using v-model, event modifiers, and reactive error handling.

In This Lesson

What Are Directives?

Directives are special attributes with a v- prefix that apply reactive behavior to the DOM. They are Vue's way of letting you declaratively connect your data to what appears on screen β€” you describe the relationship once, and Vue keeps the DOM in sync as the data changes.

πŸ’‘ An analogy: A directive is a standing instruction you pin to an HTML element β€” "show yourself only when the user is an admin," "repeat once per item in this list," "keep your value tied to this variable." You set the rule; Vue enforces it continuously.
Anatomy of a directive A directive is made of a name prefixed with v-dash, an optional argument after a colon, an optional modifier after a dot, and a JavaScript expression assigned as its value. v-on :click .prevent = "submit()" name argument modifier expression
Figure 1 β€” The four parts of a directive: v-name:argument.modifier="expression". Only the name is required.

Core Directives

v-bind β€” bind an attribute to an expression

The most-used directive. It ties any HTML attribute to a reactive value. The shorthand is a leading colon:

<!-- Full and shorthand syntax -->
<img v-bind:src="imageUrl" v-bind:alt="caption">
<img :src="imageUrl" :alt="caption">

<!-- Toggle classes with object syntax -->
<div :class="{ active: isActive, error: hasError }">Status</div>

<!-- Inline styles from data -->
<div :style="{ color: textColor, fontSize: size + 'px' }">Styled</div>

v-for β€” render a list

Repeats an element once per item. Always pair it with a unique :key so Vue can track identity efficiently:

<li v-for="item in items" :key="item.id">{{ item.name }}</li>

<!-- With an index -->
<li v-for="(item, i) in items" :key="item.id">{{ i + 1 }}. {{ item.name }}</li>

<!-- Over an object's entries -->
<li v-for="(value, key) in user" :key="key">{{ key }}: {{ value }}</li>

⚠️ The :key is not optional

Without a stable :key, Vue may reuse DOM nodes incorrectly when a list changes β€” leading to bugs like input values jumping between rows. Use a genuine unique id, not the loop index, whenever items can be added, removed, or reordered.

v-if vs v-show

Both control whether an element is visible, but they work differently under the hood.

<div v-if="userType === 'admin'">Admin panel</div>
<div v-else-if="userType === 'editor'">Editor tools</div>
<div v-else>Standard view</div>

<div v-show="isVisible">I stay in the DOM, just hidden with display:none</div>
v-ifv-show
How it hidesAdds/removes the element from the DOMToggles CSS display
Toggle costHigher (creates/destroys)Very low
Initial costNothing rendered if falseAlways rendered once
Supports v-elseYesNo

βœ… Which to choose

Use v-if when the condition rarely changes or the content is expensive to render. Use v-show when you toggle often β€” modals, tabs, tooltips β€” and can afford the one-time render cost.

Two-Way Binding with v-model

v-model creates a two-way binding on form inputs: the input reflects your data, and typing updates your data. It works on text inputs, textareas, checkboxes, radios, and selects.

<input v-model="message">
<p>Message: {{ message }}</p>

<input type="checkbox" v-model="agreed">
<select v-model="choice">
  <option disabled value="">Please choose</option>
  <option>A</option>
  <option>B</option>
</select>

Under the hood, v-model is syntactic sugar for a :value binding plus an @input listener:

<!-- This… -->
<input v-model="searchText">

<!-- …is roughly this -->
<input :value="searchText" @input="searchText = $event.target.value">

Useful modifiers

<input v-model.trim="username">      <!-- strip surrounding whitespace -->
<input v-model.number="age">         <!-- cast the value to a number -->
<input v-model.lazy="note">          <!-- sync on change, not every keystroke -->

Event Handling with v-on

The v-on directive listens for DOM events and runs code when they fire. Its shorthand is @:

<button v-on:click="count++">Add 1</button>
<button @click="count++">Add 1</button>

<!-- Call a method -->
<button @click="greet">Greet</button>
<script setup>
import { ref } from 'vue';

const count = ref(0);
const name = ref('Vue');

function greet() {
  alert(`Hello ${name.value}!`);
}
</script>

To access the native event object while also passing arguments, use the special $event variable:

<button @click="warn('Careful!', $event)">Submit</button>

<script setup>
function warn(message, event) {
  event.preventDefault();
  console.log(event.target.tagName);
  alert(message);
}
</script>

Event Modifiers

Modifiers are suffixes that handle common event chores for you, so your handler stays focused on business logic instead of DOM plumbing:

<button @click.stop="doThis">Stop propagation</button>
<form @submit.prevent="onSubmit">No page reload</form>
<a @click.stop.prevent="doThat">Chain them</a>
<button @click.once="init">Fire only once</button>

<!-- Key modifiers -->
<input @keyup.enter="submit">
<input @keyup.alt.enter="clearForm">
flowchart TD A[User action] --> B[DOM event fires] B --> C{Modifiers?} C -->|.stop| D[Stop propagation] C -->|.prevent| E[Prevent default] C -->|.once| F[Detach after first call] C -->|none| G[Run handler] D --> G E --> G F --> G

πŸ“– The two you'll use daily

.prevent on a <form @submit> stops the browser's full-page reload β€” essential for single-page apps. .stop keeps a click from bubbling to a parent handler (handy for "close" buttons inside clickable cards).

Custom Component Events

Props send data down; events send messages up. A child emits a named event with emit(), and the parent listens for it with @event-name. This is the other half of the "props down, events up" contract.

Child emits

<template>
  <button @click="add">{{ count }}</button>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(0);
const emit = defineEmits(['increment']);

function add() {
  count.value++;
  emit('increment', count.value);   // notify the parent, with a payload
}
</script>

Parent listens

<template>
  <h2>Parent total: {{ total }}</h2>
  <CounterButton @increment="onIncrement" />
</template>

<script setup>
import { ref } from 'vue';
import CounterButton from './CounterButton.vue';

const total = ref(0);
function onIncrement(childValue) {
  total.value = childValue;
}
</script>

You can also validate emitted events by passing an object to defineEmits, which documents the payload shape and warns on misuse:

const emit = defineEmits({
  increment: null,                                  // no validation
  submit: (payload) => {                            // validated
    if (!payload.email) {
      console.warn('submit event needs an email');
      return false;
    }
    return true;
  }
});

Custom Directives

When the built-in directives aren't enough for a low-level DOM task, you can write your own. A custom directive is an object of lifecycle hooks that receive the raw element.

// A simple global directive: autofocus an element on mount
app.directive('focus', {
  mounted(el) {
    el.focus();
  }
});
<input v-focus>   <!-- focused automatically when it appears -->

Directives can also read arguments, modifiers, and a value via the binding object:

app.directive('tooltip', {
  mounted(el, binding) {
    const position = binding.arg || 'top';      // v-tooltip:bottom
    const text = binding.value || 'Tooltip';    // v-tooltip="'Help'"
    const onClick = binding.modifiers.click;    // v-tooltip.click

    const tip = document.createElement('div');
    tip.className = `tooltip tooltip-${position}`;
    tip.textContent = text;
    el.appendChild(tip);

    const show = () => tip.classList.add('visible');
    const hide = () => tip.classList.remove('visible');
    if (onClick) {
      el.addEventListener('click', () => tip.classList.toggle('visible'));
    } else {
      el.addEventListener('mouseenter', show);
      el.addEventListener('mouseleave', hide);
    }
  }
});

Common real-world custom directives include autofocus, click-outside detection for dropdowns, infinite-scroll triggers, and permission-based visibility. Directive hooks (created, mounted, updated, unmounted, and their before* variants) mirror the component lifecycle.

⚠️ Reach for components first

Custom directives are for low-level DOM manipulation. If you're rendering markup or managing state, a component is almost always the better tool. Use directives sparingly.

Hands-on Exercise

πŸ‹οΈ Build a Validated Contact Form

Objective: Combine v-model, event modifiers, conditional rendering, and reactive state into a real form.

Instructions:

  1. Create a form with name, email, and message fields bound with v-model (use .trim on name and email).
  2. Handle submission with @submit.prevent so the page never reloads.
  3. On submit, validate: name required, email required and matching a basic pattern, message at least 10 characters. Store messages in a reactive errors object.
  4. Show each field's error with v-if, and add an error class to invalid inputs with :class.
  5. Disable the submit button while an isSubmitting ref is true, showing "Submitting…".
πŸ’‘ Hint

A basic email test is /^\S+@\S+\.\S+$/.test(form.email). Reset all errors at the top of your validate function before re-checking, and return a boolean so submit can bail out early when the form is invalid.

βœ… Solution (script section)
<script setup>
import { reactive, ref } from 'vue';

const form = reactive({ name: '', email: '', message: '' });
const errors = reactive({ name: '', email: '', message: '' });
const isSubmitting = ref(false);

function validate() {
  errors.name = errors.email = errors.message = '';
  let ok = true;

  if (!form.name) { errors.name = 'Name is required'; ok = false; }
  if (!form.email) { errors.email = 'Email is required'; ok = false; }
  else if (!/^\S+@\S+\.\S+$/.test(form.email)) {
    errors.email = 'Enter a valid email'; ok = false;
  }
  if (form.message.length < 10) {
    errors.message = 'Message must be at least 10 characters'; ok = false;
  }
  return ok;
}

async function submit() {
  if (!validate()) return;
  isSubmitting.value = true;
  try {
    await new Promise((r) => setTimeout(r, 1000)); // fake API call
    form.name = form.email = form.message = '';
    alert('Sent!');
  } finally {
    isSubmitting.value = false;
  }
}
</script>

The template pairs each input with v-model.trim, :class="{ error: errors.name }", and a <span v-if="errors.name">{{ errors.name }}</span>. The form uses @submit.prevent="submit" and the button is :disabled="isSubmitting".

🎯 Quick Quiz

Question 1: What is the key difference between v-if and v-show?

Question 2: Why do you add .prevent to a form's @submit handler in a single-page app?

Question 3: How does a child component send data back up to its parent?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • A directive is v-name:argument.modifier="expression"; only the name is required.
  • Core directives: v-bind (attributes), v-if/v-show (visibility), v-for (lists, always with :key), and v-model (two-way binding).
  • Choose v-if for rarely-changing conditions and v-show for frequent toggles.
  • v-on (@) handles events; modifiers like .prevent and .stop remove boilerplate.
  • Children communicate up by emitting events; custom directives handle rare low-level DOM needs.

πŸ“š Further Reading

πŸš€ What's Next?

You've now covered Vue's essentials end to end. Next we broaden the view with an overview of Angular β€” a very different, more opinionated framework β€” so you can weigh the trade-offs between the major front-end options.

πŸŽ‰ Excellent!

You can now wire data into the DOM and route user actions back into state β€” the full interactive loop.