Skip to main content
Vue.js for Beginners to Advanced
CHAPTER 09 Beginner

Event Handling in Vue.js

Updated: May 18, 2026
5 min read

# CHAPTER 9

Event Handling in Vue.js

1. Chapter Introduction

Events are how users interact with your app — clicks, key presses, form submissions, scroll, and drag. Vue's v-on directive (shorthand @) binds event listeners with optional modifiers that eliminate boilerplate code like event.preventDefault().

2. Learning Objectives

  • Handle click, input, keyboard, and mouse events.
  • Use event modifiers for common patterns.
  • Use keyboard key modifiers.
  • Access the native event object.
  • Build an interactive todo application.

3. Basic Event Handling

vue
123456789101112131415161718192021222324252627282930
<script setup>
import { ref } from &#039;vue'

const count = ref(0)
const message = ref(&#039;')

function handleClick(event) {
  console.log(&#039;Clicked!', event.target)
  count.value++
}

function handleInput(event) {
  message.value = event.target.value
}

// Inline handlers
// @click="count++" — simple inline expression
// @click="handleClick" — method reference (gets event automatically)
// @click="handleClick($event)" — explicit event passing
// @click="(e) => handleClick(e)" — arrow function
</script>

<template>
  <button @click="count++">Inline: {{ count }}</button>
  <button @click="handleClick">Method: {{ count }}</button>
  <button @click="handleClick($event)">Explicit event</button>

  <input @input="message = $event.target.value" :value="message" />
  <p>You typed: {{ message }}</p>
</template>

4. Event Modifiers

vue
123456789101112131415161718192021222324252627
<template>
  <!-- .prevent — calls event.preventDefault() -->
  <form @submit.prevent="handleSubmit">
    <button type="submit">Submit without page reload</button>
  </form>

  <!-- .stop — calls event.stopPropagation() -->
  <div @click="parentClicked">
    Parent
    <button @click.stop="childClicked">Child (won&#039;t bubble)</button>
  </div>

  <!-- .once — fires only the first time -->
  <button @click.once="sendWelcome">Send Welcome (once only)</button>

  <!-- .self — only fires if click target IS this element (not a child) -->
  <div @click.self="closeModal" class="modal-backdrop">
    <div class="modal-content">Click backdrop to close, not here</div>
  </div>

  <!-- .passive — improves scroll performance (does not call preventDefault) -->
  <div @scroll.passive="handleScroll">Scroll me</div>

  <!-- Chain modifiers -->
  <form @submit.prevent.stop="handleSubmit">...</form>
  <button @click.once.stop="doOnce">...</button>
</template>

5. Keyboard Event Modifiers

vue
123456789101112131415161718192021222324252627282930
<script setup>
import { ref } from &#039;vue'
const search = ref(&#039;')
const newTodo = ref(&#039;')

function submitSearch() { console.log(&#039;Searching for:', search.value) }
function addTodo() { console.log(&#039;Adding:', newTodo.value) }
function handleEscape() { search.value = &#039;'; console.log('Cleared') }
</script>

<template>
  <!-- Key modifiers: @keyup.{key} -->
  <input v-model="search" @keyup.enter="submitSearch" placeholder="Press Enter to search" />
  <input v-model="newTodo" @keyup.enter="addTodo" @keyup.escape="newTodo = &#039;'" />

  <!-- Multiple keys -->
  <input @keyup.enter.tab="nextField" />

  <!-- System modifier keys (Ctrl, Alt, Shift, Meta) -->
  <input @keyup.ctrl.enter="submitForm" placeholder="Ctrl+Enter to submit" />
  <div @click.ctrl="selectAll">Ctrl+Click to select all</div>
  <div @click.shift="addToSelection">Shift+Click to add to selection</div>

  <!-- Specific key aliases: -->
  <!-- .enter .tab .delete .esc .space .up .down .left .right -->
  <!-- .ctrl .alt .shift .meta (Cmd on Mac, Win key on Windows) -->

  <!-- .exact modifier — fire ONLY when EXACTLY these keys are pressed -->
  <button @click.ctrl.exact="ctrlOnly">Ctrl only (not Ctrl+Shift)</button>
</template>

6. Mouse Event Modifiers

vue
1234567891011
<template>
  <!-- Mouse button modifiers -->
  <div @click.left="leftClick">Left click</div>
  <div @click.right.prevent="showContextMenu">Right click (custom menu)</div>
  <div @click.middle="openInNewTab">Middle click</div>

  <!-- Mouse position tracking -->
  <div @mousemove="(e) => updatePosition(e.clientX, e.clientY)">
    Track mouse
  </div>
</template>

7. Mini Project: Interactive Todo App

vue
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
<!-- TodoApp.vue -->
<script setup>
import { ref, computed } from &#039;vue'

const newTodo = ref(&#039;')
const filter = ref(&#039;all')  // all | active | completed
const todos = ref([
  { id: 1, text: &#039;Learn Vue 3', done: true, priority: 'high' },
  { id: 2, text: &#039;Build a project', done: false, priority: 'medium' },
  { id: 3, text: &#039;Deploy to Vercel', done: false, priority: 'low' }
])
let nextId = 4

const filteredTodos = computed(() => {
  if (filter.value === &#039;active') return todos.value.filter(t => !t.done)
  if (filter.value === &#039;completed') return todos.value.filter(t => t.done)
  return todos.value
})

const activeCount = computed(() => todos.value.filter(t => !t.done).length)
const completedCount = computed(() => todos.value.filter(t => t.done).length)

function addTodo() {
  if (!newTodo.value.trim()) return
  todos.value.push({
    id: nextId++,
    text: newTodo.value.trim(),
    done: false,
    priority: &#039;medium'
  })
  newTodo.value = &#039;'
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}

function clearCompleted() {
  todos.value = todos.value.filter(t => !t.done)
}

function toggleAll() {
  const allDone = todos.value.every(t => t.done)
  todos.value.forEach(t => t.done = !allDone)
}
</script>

<template>
  <div class="todo-app">
    <h1>📝 Todo App</h1>

    <!-- Add todo input -->
    <div class="add-row">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        @keyup.escape="newTodo = &#039;'"
        placeholder="What needs to be done? (Enter to add)"
        class="todo-input"
      />
      <button @click="addTodo" class="add-btn">Add</button>
    </div>

    <!-- Filters -->
    <div class="filters">
      <button
        v-for="f in [&#039;all', 'active', 'completed']"
        :key="f"
        @click="filter = f"
        :class="[&#039;filter-btn', { active: filter === f }]"
      >
        {{ f.charAt(0).toUpperCase() + f.slice(1) }}
      </button>
    </div>

    <!-- Stats row -->
    <div class="stats">
      <span>{{ activeCount }} remaining</span>
      <button @click.once="toggleAll" class="link-btn">Toggle All</button>
      <button @click="clearCompleted" v-if="completedCount > 0" class="link-btn danger">
        Clear {{ completedCount }} completed
      </button>
    </div>

    <!-- Todo list -->
    <ul class="todo-list">
      <li v-for="todo in filteredTodos" :key="todo.id" class="todo-item">
        <input
          type="checkbox"
          v-model="todo.done"
          class="todo-check"
        />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <span :class="&#039;priority-' + todo.priority">{{ todo.priority }}</span>
        <button @click.stop="removeTodo(todo.id)" class="remove-btn">✕</button>
      </li>
      <li v-if="filteredTodos.length === 0" class="empty">
        {{ filter === &#039;all' ? 'No todos yet! Add one above.' : `No ${filter} todos.` }}
      </li>
    </ul>
  </div>
</template>

<style scoped>
.todo-app { max-width: 500px; margin: 2rem auto; font-family: sans-serif; background: white; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,.1); overflow: hidden; }
h1 { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; margin: 0; padding: 1.5rem 2rem; }
.add-row { display: flex; gap: .75rem; padding: 1rem 2rem; border-bottom: 1px solid #f1f5f9; }
.todo-input { flex: 1; border: 2px solid #e2e8f0; border-radius: 8px; padding: .6rem 1rem; font-size: .95rem; outline: none; transition: .2s; }
.todo-input:focus { border-color: #6366f1; }
.add-btn { background: #6366f1; color: white; border: none; border-radius: 8px; padding: .6rem 1.25rem; cursor: pointer; font-weight: 600; }
.filters { display: flex; gap: .5rem; padding: .75rem 2rem; background: #f8fafc; }
.filter-btn { background: none; border: 1px solid #e2e8f0; border-radius: 99px; padding: .3rem .9rem; cursor: pointer; font-size: .85rem; }
.filter-btn.active { background: #6366f1; color: white; border-color: #6366f1; }
.stats { display: flex; align-items: center; gap: 1rem; padding: .5rem 2rem; font-size: .85rem; color: #64748b; border-bottom: 1px solid #f1f5f9; }
.link-btn { background: none; border: none; cursor: pointer; color: #6366f1; font-size: .85rem; }
.link-btn.danger { color: #ef4444; }
.todo-list { list-style: none; margin: 0; padding: 0; }
.todo-item { display: flex; align-items: center; gap: .75rem; padding: .85rem 2rem; border-bottom: 1px solid #f1f5f9; transition: .15s; }
.todo-item:hover { background: #f8fafc; }
.todo-check { width: 18px; height: 18px; cursor: pointer; }
span.done { text-decoration: line-through; color: #94a3b8; }
.priority-high { color: #ef4444; font-size: .75rem; background: #fef2f2; padding: .15rem .5rem; border-radius: 99px; margin-left: auto; }
.priority-medium { color: #f59e0b; font-size: .75rem; background: #fffbeb; padding: .15rem .5rem; border-radius: 99px; margin-left: auto; }
.priority-low { color: #22c55e; font-size: .75rem; background: #f0fdf4; padding: .15rem .5rem; border-radius: 99px; margin-left: auto; }
.remove-btn { background: none; border: none; cursor: pointer; color: #cbd5e1; font-size: 1rem; padding: .25rem; border-radius: 4px; }
.remove-btn:hover { color: #ef4444; background: #fef2f2; }
.empty { padding: 2rem; text-align: center; color: #94a3b8; }
</style>

8. Common Mistakes

  • Not using .prevent on form submit: Without it, form submission reloads the page, losing all Vue state.
  • Using @click="method()" vs @click="method": Both work, but @click="method" passes the event automatically. @click="method()" does NOT pass the event unless you write @click="method($event)".

9. MCQs

Question 1

What does @submit.prevent do?

Question 2

@click.stop does?

Question 3

@keyup.enter fires when?

Question 4

@click.once fires?

Question 5

@click.self fires when?

Question 6

How do you access native event in inline handler?

Question 7

@click.ctrl.exact fires when?

Question 8

@keyup.escape fires on?

Question 9

.passive modifier is best for?

Question 10

Ctrl+Enter keyboard shortcut?

10. Interview Questions

  • Q: What is the difference between @click="handler" and @click="handler()"?
  • Q: When would you use the .passive event modifier?

11. Summary

Vue's event system with @event syntax and modifiers like .prevent, .stop, .once, and .self replaces dozens of lines of DOM event code. Key and mouse modifiers make keyboard shortcuts and right-click menus trivial. The todo app demonstrates how events, reactivity, and computed properties combine into a fully functional UI.

12. Next Chapter Recommendation

In Chapter 10: Conditional Rendering and Lists, we master v-if, v-else, v-for with performance optimization, and build a complete product listing application.

Finish this Chapter

Save your progress on your learning path and prepare for coding interview challenges.

Discussion

Join the discussion

Log in or create a free account to participate.

Sort: ·