β‘ 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.
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-if | v-show | |
|---|---|---|
| How it hides | Adds/removes the element from the DOM | Toggles CSS display |
| Toggle cost | Higher (creates/destroys) | Very low |
| Initial cost | Nothing rendered if false | Always rendered once |
| Supports v-else | Yes | No |
β 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">
π 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:
- Create a form with name, email, and message fields bound with
v-model(use.trimon name and email). - Handle submission with
@submit.preventso the page never reloads. - On submit, validate: name required, email required and matching a basic pattern, message at least 10 characters. Store messages in a reactive
errorsobject. - Show each field's error with
v-if, and add anerrorclass to invalid inputs with:class. - Disable the submit button while an
isSubmittingref istrue, 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.preventand.stopremove boilerplate. - Children communicate up by emitting events; custom directives handle rare low-level DOM needs.
π Further Reading
- Vue β Template Syntax
- Vue β Event Handling
- Vue β Form Input Bindings
- Vue β Component Events
- Vue β Custom Directives
π 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.