Events and form input — feeding input back into state
The previous chapter covered the direction that projects state onto the screen. This chapter goes the other way, feeding what happens on screen back into state.
There are two things to do: receive an event and call a handler, and tie an input field's value to state. Both are straightforward to write, but the details hide traps. Adding or dropping the parentheses on a handler changes whether the event object is passed, and the meaning of chaining key modifiers depends on which kind of modifier you chained.
What you'll learn in this chapter
- Choose between the three ways of writing a handler based on how the event object is passed
- Move boilerplate DOM handling into the template with event modifiers
- Know that chaining means something different for key modifiers and system modifiers, and use them accordingly
- Explain what
v-modelputs into state for each kind of input
Receiving events
Adding v-on lets you receive an event. The shorthand is @.
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const increment = () => {
count.value += 1
}
const incrementBy = (amount: number) => {
count.value += amount
}
const logTarget = (event: MouseEvent) => {
console.log(event.target)
}
</script>
<template>
<p>{{ count }}</p>
<!-- 1. write an expression inline -->
<button @click="count += 1">Write an expression</button>
<!-- 2. pass just the function name -->
<button @click="increment">Pass the function name</button>
<!-- 3. write it as a call -->
<button @click="incrementBy(5)">Pass an argument</button>
<!-- passing just the function name puts the event object in the first parameter -->
<button @click="logTarget">Receive the event</button>
<!-- to pass the event as well from a call, use $event -->
<button @click="logTarget($event)">Pass it explicitly</button>
</template>
What changes when you add the parentheses
Pass just the function name and Vue registers that function as the handler. When it is on a DOM element, the DOM calls it, so the event object goes into the first parameter.
Add parentheses and the whole expression becomes the body of the handler. incrementBy(5) is an expression meaning "call it with 5", so the event object goes nowhere. If you need it, pass it explicitly with $event.
| How it is written | First parameter | When to use it |
|---|---|---|
@click="increment" | The event object | No arguments, and you want the event |
@click="increment()" | None | No arguments, and you do not need the event |
@click="incrementBy(5)" | The argument you passed | You are passing an argument |
@click="logTarget($event)" | Passed via $event | You need both an argument and the event |
@click="count += 1" | No notion of arguments | A short expression is enough. $event is available inside it |
@click="increment" and @click="increment()" look like they produce the same result, but the former receives the event object. That difference is why an unexpected value shows up when you later add a parameter to the handler.
What lands in the first parameter depends on whether you attached it to a DOM element or a component. If you write @picked="onPicked" on your own component and the child notifies with emit('picked', 7), the first parameter is 7. No event object arrives. The same notation delivers something different, so when receiving a component's events, check what the child is passing (covered in chapter 6).
Note that script setup does not use this. Handlers are defined as ordinary functions, so there is no this binding to worry about.
Event modifiers
Boilerplate such as event.stopPropagation() and event.preventDefault() can be written in the template as modifiers.
<template>
<!-- stop propagation -->
<button @click.stop="handle">stop</button>
<!-- stop the default action; the standard way to prevent a form reload -->
<form @submit.prevent="onSubmit">
<button type="submit">Submit</button>
</form>
<!-- a modifier on its own, with no handler, is fine too -->
<form @submit.prevent></form>
<!-- fires only when event.target is the element itself -->
<div @click.self="onSelfClick">
<p>Clicking here does not fire it</p>
</div>
<!-- they can be chained -->
<a href="#" @click.stop.prevent="onLink">stop and prevent</a>
</template>
The result is the same as calling event.preventDefault() inside the handler, but making it a modifier separates the handler from the DOM's concerns. The handler holds only "what to do", while "how to receive it" stays in the template.
The main modifiers are these.
| Modifier | Details |
|---|---|
.stop | Stops propagation (stopPropagation) |
.prevent | Stops the default action (preventDefault) |
.self | Fires only when event.target is the element itself |
.once | Fires only once |
.capture | Receives during the capture phase |
.passive | Does not make the default action wait |
.once, .capture, and .passive map to the options on the native addEventListener. .passive in particular is a promise not to call preventDefault() inside the handler, telling the browser it need not wait. It pays off on high-frequency events such as scrolling.
Key modifiers and system modifiers
Keyboard events can be narrowed to specific keys with modifiers.
<script setup lang="ts">
const submit = () => {}
const save = () => {}
</script>
<template>
<!-- fires on Enter -->
<input @keyup.enter="submit" />
<!-- fires on either Enter or Space (chaining means "or") -->
<input @keyup.enter.space="submit" />
<!-- fires only on Enter while Ctrl is held ("and") -->
<input @keyup.ctrl.enter="submit" />
<!-- catch Cmd+S on macOS and Ctrl+S on Windows from the same element -->
<input @keydown.meta.s.prevent="save" @keydown.ctrl.s.prevent="save" />
</template>
meta looks only at event.metaKey; it does no per-platform translation. The Command key on macOS is metaKey, while Ctrl on Windows and Linux is ctrlKey. To catch the same gesture in both environments, write both .meta and .ctrl on the same element. As long as the modifiers differ, you can write the same event name more than once.
The meaning of chaining changes with the kind of modifier.
Chaining key modifiers such as enter and space means "any of these keys". @keyup.enter.space fires on Enter or on Space.
The system modifiers ctrl / alt / shift / meta mean "and". @keyup.ctrl.enter does not fire if you release Enter without Ctrl held.
When using system modifiers with keyup, the modifier key has to be held at the moment the key is released. Releasing Ctrl before Enter means it does not fire.
Rejecting extra modifier keys
A system modifier only checks that its key is held. It fires even when other keys are held alongside. For an exact match, add .exact.
<template>
<!-- fires even with Alt or Shift held as well -->
<button @click.ctrl="onCtrlClick">Ctrl (others may be mixed in)</button>
<!-- fires only with Ctrl alone -->
<button @click.ctrl.exact="onCtrlOnly">Ctrl only</button>
<!-- fires when no modifier key is held at all -->
<button @click.exact="onPlainClick">No modifier keys</button>
</template>
IME composition and Enter
Vue's key modifiers do not check whether an IME composition is in progress. Even with isComposing: true set on the event, the handler is called as long as event.key matches.
The v-model side does have machinery for detecting composition, and it ignores input events from the start of composition until it is committed. Key modifiers have no such check. The two are handled differently within the same library, so the fact that v-model is safe does not make key modifiers safe.
The problem is how the Enter that commits a composition arrives as a key event. That varies with the combination of browser and IME. In environments where the committing Enter arrives as event.key === 'Enter', the gesture you meant as "commit the composition" also fires the submit.
To avoid it, check event.isComposing inside the handler and do nothing while composing, or move submission to a button. If you are assigning @keyup.enter to submission on a screen where an IME is used, test it in the environments you target.
Connecting forms to state with v-model
v-model ties an input field's value to state. It is sugar that bundles reflecting the value and receiving the event.
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('')
const age = ref<number | null>(null)
const isAgreed = ref(false)
const plan = ref('')
const frameworks = ref<string[]>([])
const city = ref('')
</script>
<template>
<!-- text: a string goes in -->
<input v-model="name" type="text" />
<!-- checkbox (single): a boolean goes in -->
<input v-model="isAgreed" type="checkbox" />
<!-- checkboxes (multiple): becomes an array of values -->
<label><input v-model="frameworks" type="checkbox" value="vue" /> Vue</label>
<label><input v-model="frameworks" type="checkbox" value="react" /> React</label>
<!-- radio: the selected value goes in -->
<label><input v-model="plan" type="radio" value="free" /> Free</label>
<label><input v-model="plan" type="radio" value="pro" /> Paid</label>
<!-- select: the value of the chosen option goes in -->
<select v-model="city">
<option disabled value="">Please select</option>
<option value="sendai">Sendai</option>
<option value="tokyo">Tokyo</option>
</select>
</template>
What lands in state varies with the kind of input.
| Kind | What goes into state |
|---|---|
| Text, textarea | The string entered |
| Checkbox (single) | true or false |
| Checkboxes (several bound to the same state) | An array of the checked values |
| Radio | The chosen value |
| Select (single) | The value of the chosen option |
Select (with multiple) | An array of the chosen option values |
Bind several checkboxes to the same array and Vue takes care of adding and removing values. You do not need to manipulate the array yourself.
When putting v-model on <select multiple>, prepare the bound side as an array too. Passing a string produces a warning in the development build.
v-model modifiers
<template>
<!-- sync on the change event (the default is input) -->
<input v-model.lazy="name" />
<!-- convert to a number; with type="number" the conversion happens anyway -->
<input v-model.number="age" type="text" />
<!-- drop leading and trailing whitespace -->
<input v-model.trim="name" />
</template>
| Modifier | Details |
|---|---|
.lazy | Syncs on change rather than input |
.number | Converts to a number |
.trim | Strips leading and trailing whitespace |
The conversion in .number follows the same rules as parseFloat. Two things to watch.
First, a string that starts with digits is partially converted. "12abc" becomes 12. It does not "fail to convert"; it "reads as far as it can", so unexpected input passes through as a number.
Second, when it cannot be read as a number from the start, the original string remains. "abc" goes in as "abc", and an empty string stays an empty string. The type says number while a string can actually be in there, so validation before submission is still needed separately.
Note that .number is unnecessary on a type="number" field. Vue sees the type and converts to a number automatically.
.lazy is useful when you want fewer re-renders while typing. On a screen that displays something based on the in-progress value, though, the delayed reaction feels wrong.
Summary
- On a DOM element, passing just the function name puts the event object in the first parameter; adding parentheses does not pass it. Use
$eventwhen you need it explicitly - What lands in the first parameter depends on whether the target is a DOM element or a component. For a component's events, the
emitarguments go in - Event modifiers let you separate handlers from the DOM's concerns. Writing just
@submit.preventwith no handler is a valid form .passiveis a promise not to callpreventDefault(), which stops the default action from having to wait- Chaining key modifiers means "or"; system modifiers mean "and".
@keyup.enter.spacefires on Enter or Space, while@keyup.ctrl.enterdoes not fire unless Ctrl is held - When using system modifiers with
keyup, the modifier key must be held at the moment the key is released. Add.exactto reject extra modifier keys .metadoes no platform translation. Command on macOS ismetaKeyand Ctrl on Windows and Linux isctrlKey, so write both on the same element to catch either- Vue's key modifiers do not check for an in-progress IME composition. The
v-modelside has that check and key modifiers do not. How the committing Enter arrives depends on the environment, so test it on screens where an IME is used - What
v-modelputs into state varies with the kind of input. A checkbox gives a boolean on its own, an array when several are bound to the same state, and<select multiple>gives an array too .numberfollowsparseFloatrules."12abc"becomes 12, and when it cannot be read from the start the original string remains. Validate before treating it as a number
- Vue — Event Handling — the list of modifiers and the explanation of
.exact - Vue — Form Input Bindings — behaviour per input type, and the modifiers
- React guide — Event handling — comparison with React's synthetic events
- React guide — Forms — contrast with controlled components
What to read next
- Computed properties and watchers — deriving another value from input
- v-model between components — giving your own components a
v-model