@click: Methods, Inline Expressions, and Who Gets the Event Object
Bindings push state out to the DOM; listeners bring user actions back in. v-on:click="..." attaches a DOM listener, and the @ shorthand is what every codebase actually uses — @click, @input, @keydown, @submit. The directive takes a handler, and Vue gives you two legal shapes for it.
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const status = ref('')
function onSave(event: Event) {
// event is the real DOM event, passed automatically
console.log('target:', (event.target as HTMLElement).tagName)
status.value = 'saved'
}
function rate(value: number) {
status.value = `rated ${value}`
}
</script>
<template>
<!-- shape 1: method reference — receives the event automatically -->
<button @click="onSave">Save</button>
<!-- shape 2: inline expression — up to one statement -->
<button @click="count++">Count is {{ count }}</button>
<button @click="rate(5)">Rate 5</button>
<!-- inline with the event object: name it yourself -->
<a @click="onSave($event)">Save (link)</a>
</template>
The rule that settles most confusion: a method handler is called with the DOM event as its first argument; an inline expression does not receive it unless you write $event. So @click="onSave" and @click="onSave($event)" are equivalent, while @click="rate(5)" deliberately drops the event — fine when you do not need it, and the compiler-visible explicitness is a feature.
Inline expressions are capped at one statement (plus the optional $event argument). The moment you need two statements — validate, then send analytics, then update state — extract a method. The same goes for anything with a type: casting event.target, reading a specific property, error handling. Inline @input="value = $event.target.value" works for a bare input and immediately starts fighting TypeScript (target is EventTarget | null); the component-level pattern is a small wrapper method or, better, v-model — which is itself nothing more than a listener plus a binding, and appears in a later topic.
A distinction worth stating before it surprises you: the @ syntax listens for a component's emits when applied to a component tag, and for DOM events when applied to a native element. <MyForm @submit="handler" /> catches whatever MyForm declares in defineEmits(['submit']) and emit('submit', payload); <form @submit="handler"> catches the native submit event. Same directive, two channels — child-to-parent messages versus browser events — and the handler's argument is the emitted payload in the first case, the DOM event in the second.
Listener lifecycle follows the component: Vue adds the listener when the element mounts, updates it when the expression changes (you never re-bind by hand), and removes it when the element is unmounted. That is a real, if quiet, advantage over manual addEventListener code — no leaked listeners from forgotten removeEventListener calls, no stale closures capturing old state, because the handler re-reads the latest bindings when it runs. If you attach a native listener imperatively during testing or to a third-party element, keep doing that there; for anything inside a template, @ owns the lifecycle.
Next: the modifiers that replace the event.preventDefault() and event.stopPropagation() calls your handlers would otherwise be littered with.
One feature rounds out the basics: handlers stay testable exactly as the template declares them. A method that receives the event and does its work without knowing about the DOM is callable directly in a unit test with a plain Event object — no mounting required — which is the practical reason to prefer method references over inline expressions once logic exceeds one statement. Inline stays for one-liners where the wiring itself documents intent: a click that flips a local boolean needs no trip to a script block to be understood by the next reader.
Modifiers: .prevent, .stop, .self, .once, .passive — and Their DOM Equivalents
Modifiers are suffixes after the event name that change how the listener behaves. They are not magic — each compiles to the equivalent DOM code you would have written by hand, but written correctly and in the documented order.
| Modifier | Compiles to | Use it for |
|---|---|---|
.prevent |
event.preventDefault() |
stop a <form> from doing a full-page GET/POST |
.stop |
event.stopPropagation() |
stop a row click from also firing the table's click |
.self |
if (event.target !== event.currentTarget) return |
handle only clicks on the element itself, not on children |
.once |
listener removed after first fire | "Resend code" buttons, one-shot animations |
.capture |
registered in the capture phase | parent sees the event before its children do |
.passive |
{ passive: true } listener option |
scroll/touch handlers that must never block scrolling |
The headline use case is one line of Vue replacing a whole method body:
<!-- vanilla JS: @submit would need onSubmit(e){ e.preventDefault(); ... } -->
<form @submit.prevent="submitOrder" method="post">
<button @click.stop="select(row)">Select</button>
<button @click.once="sendInvite()">Send invite</button>
<div @scroll.passive="onScroll" class="list">…</div>
</form>
Order matters when modifiers stack, because Vue generates the calls in the order you write them: @click.stop.prevent stops propagation first, then prevents the default; @click.prevent.stop does the reverse. In practice the pair is commutative for most handlers, but @keyup.enter.prevent versus @keyup.prevent.enter differ in what runs before the key filter applies — and a modifier chain also changes whether your handler runs at all when combined with filters like .self and key modifiers. Read the chain left to right as the sequence of operations.
.self deserves its own note because it is the modifier people confuse with .stop. .stop actively halts bubbling; .self is passive filtering — the event still bubbles through, the handler just declines unless the target was this exact element. Clicking a <span> inside a @click.self panel bubbles on to ancestors as usual. That makes .self ideal for "click anywhere on the backdrop to close" without the backdrop's own children (buttons inside the modal chrome) triggering the close.
.passive is the performance modifier and it carries one hard constraint: a passive listener may not call preventDefault — the browser is told upfront that it will not be blocked from scrolling, and a handler that then tries to block it is ignored (the console may warn). It pairs naturally with wheel, touchstart, and mousemove on scrollable containers, where waiting on JavaScript to decide about scrolling costs frames. Do not sprinkle it on click handlers "just in case"; it changes nothing there.
Two supporting rules round it out. Modifiers work on any v-on — @keydown.esc below, @submit.prevent above — and they compose with .once and .capture in the same chain. And none of this requires the event object in your method: @submit.prevent="submitOrder" calls submitOrder(event) with a default already cancelled, so the method receives the event but no longer needs to touch preventDefault. The manual calls remain correct — a handler that calls event.stopPropagation() itself still works — but in review, a chain of modifiers documents intent in the template where the behavior is wired.
Key Modifiers: Filtering Keyboard Input by event.key
A search box fires @keyup on every keystroke, and you only want Enter. @keyup.enter filters by event.key — the modifier name maps to a value of the standard KeyboardEvent key property — so the handler runs only when the released key was Enter.
<script setup lang="ts">
import { ref } from 'vue'
const query = ref('')
const submitted = ref(false)
function search() { submitted.value = true }
</script>
<template>
<input
v-model="query"
@keyup.enter="search"
@keyup.esc="query = ''"
@keydown.enter.ctrl="saveSearch"
/>
</template>
The exact-key names and what they compare against:
| Modifier | event.key value |
Notes |
|---|---|---|
enter |
'Enter' |
submit inputs, confirm dialogs |
tab |
'Tab' |
rare, but legal to filter |
delete |
'Delete' or 'Backspace' |
covers both keys by design |
esc |
'Escape' |
alias for the full name |
space |
' ' |
the key value is a single space character |
up / down / left / right |
'ArrowUp' … |
aliases for the Arrow names |
home / end / page-up / page-down |
matching 'Home', 'End', … |
exact names otherwise |
Numeric key codes (@keyup.13) were the Vue 2 workaround for old browsers and are deprecated in Vue 3 — the framework compares event.key (with aliases normalized), and event.keyCode no longer exists in the standard. Letter keys are matched case-insensitively: @keyup.a fires whether Caps Lock or Shift made the value 'A'. When the key is data — a shortcut list from settings, an i18n label — skip modifiers and read event.key inside the handler; modifiers are for fixed, single-key intent.
System modifiers require the combination, not just the key: @keydown.ctrl.enter fires only when Enter is pressed while Ctrl is held. ctrl, alt, shift, meta (Cmd on macOS) work in any combination — @keyup.meta.shift.z is the familiar undo chord. Their semantics track the platform where it matters: meta is the Cmd key on macOS and the Windows key elsewhere, which is exactly why cross-platform undo is written as two shortcuts or handled in script via event.key === 'z' && (event.metaKey || event.ctrlKey).
Two refinements worth knowing. .exact removes extra modifiers: @click.ctrl.exact fires only when Ctrl is the sole pressed key — a plain click or Ctrl+Shift+click does not trigger it; useful when a chord must mean one thing. And @keyup versus @keydown changes feel more than logic: keyup fires after auto-repeat floods in when a key is held, so shortcuts generally live on keydown (with .prevent to stop the browser's own handling, e.g. for arrow keys inside a custom listbox) while "press Enter to submit" pairs naturally with the input's own keyup.
The full picture of this phase is now assembled: interpolation renders values, conditionals choose existence, bindings push state to attributes, and listeners plus modifiers bring intent back from the user. Next: List Rendering with v-for — where the :key you have seen in passing becomes the central correctness requirement.
A quick preview of the same idea on the component channel: a keyup listener on a component tag does not listen for a DOM event — it listens for an emitted keyup event of that name, which almost no component declares. Native key filtering belongs on the native input inside the component, or the component must declare and forward the event itself. Keeping the two channels straight — DOM events on native elements, emits on components — prevents the recurring mystery of a modifier that never fires no matter which key you press.
Quiz
1. What is the difference between `<button @click="onSave">` and `<button @click="onSave($event)">`?
2. A modal backdrop has `@click="close"` and contains a `<button class="close" @click.stop>`. What does clicking the button do?
3. For a search box that should search on Enter and clear on Esc, which template is correct?
4. What is the effect of `<form @submit.prevent="sendOrder">` compared to calling event.preventDefault() inside sendOrder?
Flashcards
Question
How do you attach a listener with v-on, and what are its two handler shapes?
Click to reveal answer
Answer
v-on:click="...", shorthand @click. Shapes: (1) method reference — Vue calls it with the DOM event as first argument; (2) inline expression — one statement, no event unless you name it with $event (@click="fn($event)"). Inline is capped at one statement; anything typed or multi-step goes in a method.
Question
Name the main v-on modifiers and the DOM code each compiles to.
Click to reveal answer
Answer
.prevent → event.preventDefault() (stop form page reload) · .stop → event.stopPropagation() (block bubbling) · .self → bail unless event.target === currentTarget · .once → remove listener after first fire · .capture → capture-phase registration · .passive → {passive:true} (never blocks scrolling, so no preventDefault). Chains run in written order.
Question
How do key modifiers work, and what do they compare against?
Click to reveal answer
Answer
They filter on event.key: @keyup.enter fires only when the key value is 'Enter'. Aliases: esc→Escape, space→' ', up/down/left/right→'Arrow*', delete→Delete OR Backspace. Numeric codes (@keyup.13) are deprecated. Letter keys match case-insensitively; for dynamic key sets read event.key in the handler.
Question
What do system modifiers and .exact do?
Click to reveal answer
Answer
ctrl/alt/shift/meta require that key held during the event (@keydown.ctrl.enter = Ctrl+Enter). .exact removes extras: @click.ctrl.exact fires only when Ctrl is the sole modifier (plain click or Ctrl+Shift+click don't). meta is Cmd on macOS, Windows key elsewhere — cross-platform chords often read event.metaKey || event.ctrlKey in script instead.
Question
Why do @-listeners on component tags behave differently from those on native elements?
Click to reveal answer
Answer
On a native element, @click listens to the DOM event. On a component tag, @submit listens for the component's emitted 'submit' — the handler's argument is the emit payload, and only declared emits pass through. Same directive, two channels: browser events vs child-to-parent messages.
Revision Notes
Key Takeaways
- 1. v-on (@) takes either a method reference (invoked with the DOM event) or a one-statement inline expression that needs $event to see the event
- 2. Modifiers compile to the vanilla equivalents — prevent, stop, self, once, capture, passive — and stack in the order written
- 3. Key modifiers filter on event.key with documented aliases; numeric key codes are deprecated, and system modifiers require the chord while .exact forbids extra keys
- 4. Vue owns the listener lifecycle: handlers are rebound and removed with the element, eliminating the stale-closure and leaked-listener bugs of manual addEventListener
- 5. @ on a component tag listens to emits instead of DOM events — the same directive carrying child-to-parent messages
Interview Tips
- • "What does @submit.prevent actually do?" — compiles to preventDefault around your handler; note it runs regardless of the handler's code path, and contrast with doing it manually where an early return or throw skips it
- • "Difference between .stop and .self?" — stop actively halts bubbling; self passively filters (handler skips unless the target is the element itself) while the event keeps bubbling — pair with a modal backdrop example
- • "How do you implement Ctrl+Enter submit?" — @keydown.enter.ctrl on the input; mention .exact semantics and the metaKey/ctrlKey platform split for cross-platform chords
- • "Do component listeners leak?" — no: Vue registers and removes listeners with the element/instance lifecycle; explain the emitted-events channel for @ on components to show the full model
Cheat Sheet
Event Handling: v-on (@)
Two handler shapes:
<button @click="onSave">…</button> <!-- method: called with the DOM event -->
<button @click="count++">…</button> <!-- inline: one statement, no event -->
<button @click="onSave($event)">…</button> <!-- inline + explicit event -->
<a @click="rate(5)">…</a> <!-- inline, event dropped on purpose -->
Modifiers (compile to the DOM equivalent, applied in written order):
| Modifier | Equivalent |
|---|---|
.prevent |
event.preventDefault() — form submit stays on the page |
.stop |
event.stopPropagation() |
.self |
ignore unless event.target === currentTarget (still bubbles) |
.once / .capture / .passive |
one-shot / capture phase / {passive:true} (never with preventDefault) |
Keys & systems:
<input @keyup.enter="search" @keyup.esc="query=''" />
<input @keydown.enter.ctrl="save" /> <!-- chord -->
<div @click.ctrl.exact="onChord">…</div> <!-- Ctrl only -->
- Names map to
event.key: enter, tab, delete (Delete+Backspace), esc, space (' '), up/down/left/right ('Arrow*'). @keyup.13(numeric codes) is deprecated in Vue 3.
Rules: inline capped at one statement · typed events ((e.target as HTMLInputElement).value) belong in methods or behind v-model · listeners are added/updated/removed by Vue — no manual addEventListener bookkeeping · @ on a component = emits, @ on a native element = DOM events.