Introduction to Vue Directives
Directives are special attributes with the v- prefix that apply reactive behavior to the DOM. They are one of Vue's most powerful features, allowing you to declaratively manipulate the DOM based on your application state.
Think of directives as special instructions you give to HTML elements, like telling a robot what to do under certain conditions. They create a bridge between your data and the DOM, automatically updating the UI when data changes.
Anatomy of a Directive
<element
v-directive:argument.modifier="expression"
></element>
Where:
- v-directive: The directive name (e.g., v-bind, v-if)
- argument: Optional parameter (e.g., v-bind:href, v-on:click)
- modifier: Optional modifier that changes the directive's behavior (e.g., v-on:submit.prevent)
- expression: A JavaScript expression that's evaluated against the component's data
Core Directives
Vue provides several built-in directives for common tasks:
v-bind
Dynamically binds an attribute to an expression. This is one of the most frequently used directives.
<!-- Full syntax -->
<img v-bind:src="imageUrl" v-bind:alt="imageDescription">
<!-- Shorthand syntax (more common) -->
<img :src="imageUrl" :alt="imageDescription">
Real-world example: Binding dynamic class names based on application state:
<!-- Binding classes with object syntax -->
<div
:class="{
'active': isActive,
'error': hasError,
'disabled': isDisabled
}"
>Dynamic Classes</div>
<!-- Binding inline styles -->
<div
:style="{
color: textColor,
fontSize: fontSize + 'px',
backgroundColor: bgColor
}"
>Dynamic Styles</div>
In e-commerce applications, v-bind is used extensively to:
- Update product images based on selected variants
- Highlight selected filters
- Show different UI elements based on user roles
- Enable/disable form elements based on user inputs
v-if, v-else-if, v-else
Conditionally renders elements based on the truthiness of an expression. These directives actually add/remove elements from the DOM.
<div v-if="userType === 'admin'">
Admin Panel
</div>
<div v-else-if="userType === 'moderator'">
Moderator Controls
</div>
<div v-else>
User Content
</div>
Real-world examples:
- Showing different navigation items based on user permissions
- Displaying error messages only when form validation fails
- Implementing multi-step forms with conditional steps
- Showing loading states while fetching data
v-show
Similar to v-if, but toggles the element's CSS display property instead of actually adding/removing it from the DOM.
<div v-show="isVisible">This element stays in the DOM, but is hidden when isVisible is false</div>
When to use v-show vs v-if:
- Use v-if when:
- The condition rarely changes
- The content is expensive to render
- You need to control when components are created/destroyed
- Use v-show when:
- The condition changes frequently
- You need to toggle visibility often (e.g., modals, tooltips)
- Initial render cost is acceptable for better toggle performance
v-for
Renders an element or template block multiple times based on data.
<!-- Basic Array Iteration -->
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
<!-- With Index -->
<ul>
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
</ul>
<!-- Object Properties -->
<ul>
<li v-for="(value, key) in userObject" :key="key">
{{ key }}: {{ value }}
</li>
</ul>
<!-- Iterating a Fixed Number of Times -->
<span v-for="n in 5" :key="n">{{ n }}</span>
The :key attribute is crucial for Vue to track each node's identity, allowing it to reuse and reorder elements efficiently. Always use unique keys with v-for.
Real-world examples of v-for:
- Rendering product lists in e-commerce sites
- Creating data tables with sortable columns
- Building interactive dashboards with multiple widgets
- Displaying user notifications or messages
v-model
Creates two-way data binding on form inputs, textareas, and select elements.
<!-- Text Input -->
<input v-model="message">
<p>Message: {{ message }}</p>
<!-- Checkbox -->
<input type="checkbox" v-model="isChecked">
<p>Checked: {{ isChecked }}</p>
<!-- Radio Buttons -->
<input type="radio" v-model="picked" value="one">
<input type="radio" v-model="picked" value="two">
<p>Picked: {{ picked }}</p>
<!-- Select Dropdown -->
<select v-model="selected">
<option disabled value="">Please select one</option>
<option>A</option>
<option>B</option>
<option>C</option>
</select>
<p>Selected: {{ selected }}</p>
Under the hood, v-model is syntactic sugar that combines v-bind and v-on:
<!-- This: -->
<input v-model="searchText">
<!-- Is roughly equivalent to: -->
<input
:value="searchText"
@input="searchText = $event.target.value"
>
v-model Modifiers
Vue provides modifiers to customize v-model behavior:
<!-- Trim whitespace -->
<input v-model.trim="username">
<!-- Convert to number -->
<input v-model.number="age" type="number">
<!-- Update only after change event (when input loses focus) -->
<input v-model.lazy="message">
Event Handling with v-on
The v-on directive lets you listen to DOM events and run JavaScript when they're triggered.
<!-- Full syntax -->
<button v-on:click="counter += 1">Add 1</button>
<!-- Shorthand syntax (more common) -->
<button @click="counter += 1">Add 1</button>
You can call methods in event handlers:
<button @click="greet">Greet</button>
<script>
export default {
methods: {
greet() {
alert('Hello ' + this.name + '!');
}
},
data() {
return {
name: 'Vue.js'
}
}
}
</script>
With the Composition API:
<template>
<button @click="greet">Greet</button>
</template>
<script setup>
import { ref } from 'vue';
const name = ref('Vue.js');
function greet() {
alert(`Hello ${name.value}!`);
}
</script>
Accessing Event Object
You can access the native DOM event object in your handlers:
<!-- Using $event special variable -->
<button @click="warn('Warning!', $event)">
Submit
</button>
<!-- In method -->
methods: {
warn(message, event) {
// Access event
if (event) {
event.preventDefault();
console.log(event.target.tagName);
}
alert(message);
}
}
Event Modifiers
Vue provides event modifiers to simplify common tasks:
<!-- Stop propagation -->
<button @click.stop="doThis">Stop</button>
<!-- Prevent default -->
<form @submit.prevent="onSubmit">Submit</form>
<!-- Chain modifiers -->
<a @click.stop.prevent="doThat">Stop & Prevent</a>
<!-- Only trigger once -->
<button @click.once="doOnce">Once</button>
<!-- Key modifiers -->
<input @keyup.enter="submit">
<!-- Key AND modifier combinations -->
<input @keyup.alt.enter="clearForm">
<!-- Mouse button modifiers -->
<button @click.right="showContext">Right Click</button>
Real-world applications:
- Form submission handling with validation
- Implementing keyboard shortcuts in web applications
- Creating custom drag-and-drop interfaces
- Building interactive data visualizations
- Implementing modal dialogs with click-outside-to-close behavior
Form Input Binding with v-model
Creating forms in Vue is straightforward thanks to v-model. Let's look at a complete example of a form with validation:
<template>
<form @submit.prevent="submitForm">
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model.trim="form.name"
:class="{ 'error': errors.name }"
>
<span v-if="errors.name" class="error-message">{{ errors.name }}</span>
</div>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
type="email"
v-model.trim="form.email"
:class="{ 'error': errors.email }"
>
<span v-if="errors.email" class="error-message">{{ errors.email }}</span>
</div>
<div class="form-group">
<label for="age">Age</label>
<input
id="age"
type="number"
v-model.number="form.age"
:class="{ 'error': errors.age }"
>
<span v-if="errors.age" class="error-message">{{ errors.age }}</span>
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea
id="message"
v-model="form.message"
:class="{ 'error': errors.message }"
></textarea>
<span v-if="errors.message" class="error-message">{{ errors.message }}</span>
</div>
<div class="form-group">
<label>
<input type="checkbox" v-model="form.terms">
I agree to the terms and conditions
</label>
<span v-if="errors.terms" class="error-message">{{ errors.terms }}</span>
</div>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</button>
</form>
</template>
<script setup>
import { reactive, ref } from 'vue';
const form = reactive({
name: '',
email: '',
age: null,
message: '',
terms: false
});
const errors = reactive({
name: '',
email: '',
age: '',
message: '',
terms: ''
});
const isSubmitting = ref(false);
function validateForm() {
let isValid = true;
// Reset errors
Object.keys(errors).forEach(key => {
errors[key] = '';
});
// Validate name
if (!form.name) {
errors.name = 'Name is required';
isValid = false;
}
// Validate email
if (!form.email) {
errors.email = 'Email is required';
isValid = false;
} else if (!/^\S+@\S+\.\S+$/.test(form.email)) {
errors.email = 'Please enter a valid email address';
isValid = false;
}
// Validate age
if (form.age === null || form.age === '') {
errors.age = 'Age is required';
isValid = false;
} else if (form.age < 18 || form.age > 120) {
errors.age = 'Age must be between 18 and 120';
isValid = false;
}
// Validate message
if (!form.message) {
errors.message = 'Message is required';
isValid = false;
} else if (form.message.length < 10) {
errors.message = 'Message must be at least 10 characters';
isValid = false;
}
// Validate terms
if (!form.terms) {
errors.terms = 'You must agree to the terms and conditions';
isValid = false;
}
return isValid;
}
async function submitForm() {
if (!validateForm()) {
return;
}
isSubmitting.value = true;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
// Reset form after successful submission
Object.keys(form).forEach(key => {
if (typeof form[key] === 'boolean') {
form[key] = false;
} else {
form[key] = typeof form[key] === 'number' ? null : '';
}
});
alert('Form submitted successfully!');
} catch (error) {
alert('An error occurred. Please try again.');
console.error(error);
} finally {
isSubmitting.value = false;
}
}
</script>
<style scoped>
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
}
input, textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.error {
border-color: red;
}
.error-message {
color: red;
font-size: 0.8rem;
margin-top: 0.25rem;
display: block;
}
button {
padding: 0.5rem 1rem;
background-color: #42b883;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
</style>
This form demonstrates:
- Using v-model with different input types
- Form validation with reactive error handling
- Loading states during submission
- Class binding for error styling
- Preventing the default form submission
Custom Directives
While Vue's built-in directives cover most use cases, you can create custom directives for specialized DOM manipulations.
// Global custom directive
app.directive('focus', {
mounted(el) {
el.focus();
}
});
// Usage in a component
<input v-focus>
Custom directive with arguments and modifiers:
// Tooltip directive
app.directive('tooltip', {
mounted(el, binding) {
const position = binding.arg || 'top';
const tooltipText = binding.value || 'Default tooltip';
const showOnClick = binding.modifiers.click || false;
// Create tooltip element
const tooltip = document.createElement('div');
tooltip.className = `tooltip tooltip-${position}`;
tooltip.textContent = tooltipText;
el.appendChild(tooltip);
// Add necessary event listeners
if (showOnClick) {
el.addEventListener('click', () => {
tooltip.classList.toggle('visible');
});
} else {
el.addEventListener('mouseenter', () => {
tooltip.classList.add('visible');
});
el.addEventListener('mouseleave', () => {
tooltip.classList.remove('visible');
});
}
}
});
// Usage
<button v-tooltip:bottom.click="'Click to learn more'">Help</button>
Directive Hook Functions
Custom directives have several lifecycle hooks:
created: Called before the element's attributes or event listeners are appliedbeforeMount: Called right before the element is inserted into the DOMmounted: Called when the element is inserted into the DOMbeforeUpdate: Called before the containing component's VNode is updatedupdated: Called after the containing component's VNode and its children have updatedbeforeUnmount: Called before the element is removed from the DOMunmounted: Called after the element has been removed from the DOM
Real-world examples of custom directives:
- Autofocus directives for form inputs
- Tooltips and popover positioning
- Scroll position tracking and infinite scrolling
- Click-outside detection for dropdowns and modals
- Input masking and formatting
- Permission-based element visibility
Component Events
In addition to DOM events, Vue components can emit custom events to communicate with parent components.
Emitting Events from Child Components
Options API:
<template>
<button @click="incrementCounter">{{ count }}</button>
</template>
<script>
export default {
data() {
return {
count: 0
}
},
methods: {
incrementCounter() {
this.count++;
this.$emit('increment', this.count);
}
}
}
</script>
Composition API:
<template>
<button @click="incrementCounter">{{ count }}</button>
</template>
<script setup>
import { ref, defineEmits } from 'vue';
const count = ref(0);
const emit = defineEmits(['increment']);
function incrementCounter() {
count.value++;
emit('increment', count.value);
}
</script>
Listening for Events in Parent Components
<template>
<div>
<h2>Parent Counter: {{ parentCount }}</h2>
<counter-button @increment="updateParentCount" />
</div>
</template>
<script setup>
import { ref } from 'vue';
import CounterButton from './CounterButton.vue';
const parentCount = ref(0);
function updateParentCount(childValue) {
parentCount.value = childValue;
console.log(`Child sent value: ${childValue}`);
}
</script>
Validating Emitted Events
You can define and validate the events a component emits:
// Options API
export default {
emits: {
// No validation
'increment': null,
// With validation
'submit': (payload) => {
if (!payload.email) {
console.warn('Missing email in submit event payload');
return false;
}
return true;
}
},
methods: {
submitForm() {
this.$emit('submit', { email: this.email });
}
}
}
// Composition API
const emit = defineEmits({
increment: null,
submit: (payload) => {
if (!payload.email) {
console.warn('Missing email in submit event payload');
return false;
}
return true;
}
});
Defining your emitted events provides better documentation and allows for validation, making your components more robust and self-documenting.
Putting It All Together: Building an Interactive Component
Let's combine everything we've learned to build a shopping cart line item component:
<template>
<div class="cart-item" :class="{ 'cart-item--out-of-stock': !isInStock }">
<div class="cart-item__image">
<img :src="product.image" :alt="product.name">
</div>
<div class="cart-item__details">
<h3>{{ product.name }}</h3>
<p v-if="product.description">{{ product.description }}</p>
<div class="cart-item__price">
<span v-if="product.discountedPrice">
<s>{{ formatPrice(product.price) }}</s>
{{ formatPrice(product.discountedPrice) }}
</span>
<span v-else>
{{ formatPrice(product.price) }}
</span>
</div>
<div v-if="!isInStock" class="cart-item__out-of-stock">
Out of stock
</div>
</div>
<div class="cart-item__quantity" v-if="isInStock">
<button
class="quantity-btn"
@click="updateQuantity(quantity - 1)"
:disabled="quantity <= 1"
>
-
</button>
<input
type="number"
v-model.number="localQuantity"
min="1"
:max="product.maxQuantity || 99"
@blur="onQuantityBlur"
>
<button
class="quantity-btn"
@click="updateQuantity(quantity + 1)"
:disabled="product.maxQuantity && quantity >= product.maxQuantity"
>
+
</button>
</div>
<div class="cart-item__subtotal">
{{ formatPrice(subtotal) }}
</div>
<button class="cart-item__remove" @click.stop="$emit('remove')">
×
</button>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
product: {
type: Object,
required: true
},
quantity: {
type: Number,
required: true
}
});
const emit = defineEmits(['update:quantity', 'remove']);
// Local quantity state for the input
const localQuantity = ref(props.quantity);
// Watch for external quantity changes
watch(() => props.quantity, (newQuantity) => {
localQuantity.value = newQuantity;
});
// Computed properties
const isInStock = computed(() => {
return props.product.inStock !== false; // Treat undefined as in stock
});
const subtotal = computed(() => {
const price = props.product.discountedPrice || props.product.price;
return price * props.quantity;
});
// Methods
function formatPrice(value) {
return `$${value.toFixed(2)}`;
}
function updateQuantity(newQuantity) {
if (newQuantity < 1) return;
// Check max quantity if defined
if (props.product.maxQuantity && newQuantity > props.product.maxQuantity) {
newQuantity = props.product.maxQuantity;
}
localQuantity.value = newQuantity;
emit('update:quantity', newQuantity);
}
function onQuantityBlur() {
// Handle empty or invalid values
if (!localQuantity.value || localQuantity.value < 1) {
localQuantity.value = 1;
}
// Ensure it's a number and within bounds
let value = parseInt(localQuantity.value);
if (props.product.maxQuantity && value > props.product.maxQuantity) {
value = props.product.maxQuantity;
}
localQuantity.value = value;
emit('update:quantity', value);
}
</script>
<style scoped>
.cart-item {
display: flex;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
position: relative;
}
.cart-item--out-of-stock {
opacity: 0.7;
}
.cart-item__image {
width: 80px;
height: 80px;
overflow: hidden;
margin-right: 1rem;
}
.cart-item__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cart-item__details {
flex: 1;
}
.cart-item__details h3 {
margin: 0 0 0.5rem 0;
}
.cart-item__price {
font-weight: bold;
margin: 0.5rem 0;
}
.cart-item__price s {
color: #999;
margin-right: 0.5rem;
}
.cart-item__out-of-stock {
color: #e74c3c;
font-weight: bold;
margin-top: 0.5rem;
}
.cart-item__quantity {
display: flex;
align-items: center;
margin: 0 1.5rem;
}
.cart-item__quantity input {
width: 3rem;
text-align: center;
margin: 0 0.5rem;
}
.quantity-btn {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.quantity-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cart-item__subtotal {
font-weight: bold;
width: 6rem;
text-align: right;
}
.cart-item__remove {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
margin-left: 1rem;
}
.cart-item__remove:hover {
color: #e74c3c;
}
</style>
This component demonstrates:
- Using v-if and v-else for conditional rendering
- Binding attributes with v-bind (:)
- Class and style binding based on component state
- Event handling with modifiers
- Two-way binding with v-model and custom events
- Computed properties for derived values
- Watch to react to prop changes
- Emitting events for parent communication
Using the Component
<template>
<div class="shopping-cart">
<h2>Your Cart</h2>
<div v-if="cart.items.length === 0" class="empty-cart">
Your cart is empty. <a href="/products">Continue shopping</a>
</div>
<div v-else>
<div class="cart-header">
<div class="cart-header__product">Product</div>
<div class="cart-header__quantity">Quantity</div>
<div class="cart-header__subtotal">Subtotal</div>
<div class="cart-header__remove"> </div>
</div>
<cart-item
v-for="item in cart.items"
:key="item.id"
:product="item.product"
v-model:quantity="item.quantity"
@remove="removeItem(item.id)"
/>
<div class="cart-footer">
<div class="cart-total">
<span>Total:</span>
<span>{{ formatPrice(cartTotal) }}</span>
</div>
<button
class="checkout-button"
@click="checkout"
:disabled="isCheckingOut"
>
{{ isCheckingOut ? 'Processing...' : 'Proceed to Checkout' }}
</button>
</div>
</div>
</div>
</template>
Practice Activity
Interactive Product Configurator
Build a product configurator that allows users to customize a product with various options. The component should:
- Display a base product with customizable options (color, size, features, etc.)
- Allow users to select options through different input types
- Update the displayed product image based on selections
- Calculate the price dynamically based on selected options
- Show/hide certain options based on other selections
- Validate that the configuration is valid before allowing "Add to Cart"
Start with this data structure:
const product = {
name: 'Customizable Widget',
basePrice: 49.99,
baseImage: '/images/widget-default.jpg',
colors: [
{ id: 'red', name: 'Red', image: '/images/widget-red.jpg', priceModifier: 0 },
{ id: 'blue', name: 'Blue', image: '/images/widget-blue.jpg', priceModifier: 0 },
{ id: 'green', name: 'Green', image: '/images/widget-green.jpg', priceModifier: 5 }
],
sizes: [
{ id: 'small', name: 'Small', priceModifier: -5 },
{ id: 'medium', name: 'Medium', priceModifier: 0 },
{ id: 'large', name: 'Large', priceModifier: 10 }
],
features: [
{ id: 'wifi', name: 'WiFi Connectivity', priceModifier: 15, compatible: ['small', 'medium', 'large'] },
{ id: 'bluetooth', name: 'Bluetooth', priceModifier: 10, compatible: ['small', 'medium', 'large'] },
{ id: 'gps', name: 'GPS Module', priceModifier: 20, compatible: ['medium', 'large'] },
{ id: 'battery', name: 'Extended Battery', priceModifier: 25, compatible: ['medium', 'large'] }
]
};
const selectedOptions = {
color: 'red',
size: 'medium',
features: []
};
Use the directives and event handling techniques covered in this lecture to build an interactive UI for this configurator.
Key Takeaways
- Vue directives are special attributes that provide reactive behavior to the DOM
- Core directives include v-bind, v-if/v-else, v-show, v-for, and v-model
- Event handling with v-on (or @ shorthand) allows components to respond to user interactions
- Event modifiers simplify common event handling tasks
- v-model provides two-way data binding for form inputs
- Custom directives extend Vue's capabilities for specialized DOM manipulations
- Component events facilitate parent-child communication
- Combining these features allows you to build complex, interactive components