Skip to main content

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-model puts into state for each kind of input

Receiving events​

Adding v-on lets you receive an event. The shorthand is @.

components/Counter.vue
<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 writtenFirst parameterWhen to use it
@click="increment"The event objectNo arguments, and you want the event
@click="increment()"NoneNo arguments, and you do not need the event
@click="incrementBy(5)"The argument you passedYou are passing an argument
@click="logTarget($event)"Passed via $eventYou need both an argument and the event
@click="count += 1"No notion of argumentsA 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.

ModifierDetails
.stopStops propagation (stopPropagation)
.preventStops the default action (preventDefault)
.selfFires only when event.target is the element itself
.onceFires only once
.captureReceives during the capture phase
.passiveDoes 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.

components/SearchBox.vue
<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.

components/ProfileForm.vue
<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.

KindWhat goes into state
Text, textareaThe string entered
Checkbox (single)true or false
Checkboxes (several bound to the same state)An array of the checked values
RadioThe 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>
ModifierDetails
.lazySyncs on change rather than input
.numberConverts to a number
.trimStrips 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 $event when 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 emit arguments go in
  • Event modifiers let you separate handlers from the DOM's concerns. Writing just @submit.prevent with no handler is a valid form
  • .passive is a promise not to call preventDefault(), which stops the default action from having to wait
  • Chaining key modifiers means "or"; system modifiers mean "and". @keyup.enter.space fires on Enter or Space, while @keyup.ctrl.enter does 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 .exact to reject extra modifier keys
  • .meta does no platform translation. Command on macOS is metaKey and Ctrl on Windows and Linux is ctrlKey, 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-model side 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-model puts 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
  • .number follows parseFloat rules. "12abc" becomes 12, and when it cannot be read from the start the original string remains. Validate before treating it as a number
Related references