Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Events & Forms

v-on, modifiers and two-way binding with v-model.

Interfaces respond to people. Clicks, key presses and form input reach a Vue component through v-on (shorthand @), and form fields stay in sync with state through v-model. This lesson covers both, plus the modifiers that remove most of the boilerplate you'd otherwise write by hand.

By the end you'll have built the Task board's "Add task" form: validated, keyboard-friendly, and two-way bound.

Listening to events with v-on

<script setup>
import { ref } from 'vue';

const count = ref(0);
const log = ref([]);

function increment(step = 1) {
    count.value += step;
}

function logEvent(event) {
    const tag = event.target.tagName.toLowerCase();
    log.value.unshift(`${event.type} on <${tag}> at x=${event.offsetX}, y=${event.offsetY}`);
    log.value = log.value.slice(0, 4);   // keep the last four entries
}
</script>

<template>
    <!-- 1. Inline expression -->
    <button @click="count++">+1</button>

    <!-- 2. Method name: Vue passes the native event -->
    <button @click="logEvent">Log the event</button>

    <!-- 3. Method call with arguments; $event is the native event -->
    <button @click="increment(5)">+5</button>
    <button @click="increment(10); logEvent($event)">+10 and log</button>

    <p>count: {{ count }}</p>
    <ul><li v-for="(line, i) in log" :key="i + line">{{ line }}</li></ul>
</template>

Vue looks at the handler and decides how to call it:

  • A method name or path (logEvent, foo.bar) is called with the native Event object.
  • Anything else is treated as an inline statement and wrapped in a function. Use the special $event variable to reach the event.

Event modifiers

A lot of event-handling code is DOM plumbing: event.preventDefault(), event.stopPropagation(), checking event.key. Vue lets you declare that in the template with modifiers, so your methods only contain logic:

<!-- Illustrative. The .stop and .prevent modifiers run in the demos further down. -->
<template>
    <!-- Submit without reloading the page -->
    <form @submit.prevent="addTask">…</form>

    <!-- Don't let the click bubble up to the card -->
    <button @click.stop="deleteTask(task)">🗑</button>

    <!-- Only fire when the click is on this element itself, not a child -->
    <div class="modal-backdrop" @click.self="closeModal">…</div>

    <!-- Fire at most once -->
    <button @click.once="startTour">Start tour</button>

    <!-- Passive listener for smooth scrolling on touch devices -->
    <div @scroll.passive="onScroll">…</div>
</template>
Modifier Equivalent JavaScript
.prevent event.preventDefault()
.stop event.stopPropagation()
.self if (event.target !== event.currentTarget) return
.once Remove the listener after the first call
.capture addEventListener(…, { capture: true })
.passive addEventListener(…, { passive: true })

Modifiers can be chained, and order matters: @click.prevent.self prevents the default for clicks on the element and its children, while @click.self.prevent only prevents clicks on the element itself.

How events propagate

div.board @click="deselectAll" li.task @click="openTask" button @click.stop="deleteTask" Without .stop: click bubbles button → li → div (all 3 fire) With .stop: only the button handler runs

Try it. The demo's markup mirrors the diagram:

<div class="board" @click="record('deselectAll (board)')">
    <div class="task" @click="record('openTask (li)')">
        <button @click.stop="record('deleteTask (button)')">🗑 Delete</button>
    </div>
</div>

Modifiers are fixed at compile time, so to let you switch .stop on and off, the demo's button handler calls event.stopPropagation() itself when the checkbox is ticked. That's exactly what .stop compiles to. Click the 🗑 button with it on and off, and watch the event log:

Key and mouse modifiers

<!-- Illustrative. The "Add task" form below uses @keyup.esc for real. -->
<template>
    <input @keyup.enter="addTask" @keyup.esc="newTitle = ''">

    <!-- System modifier keys -->
    <input @keydown.ctrl.enter="saveAndClose">

    <!-- .exact: ONLY ctrl, no shift/alt/meta -->
    <button @click.ctrl.exact="selectOne">Ctrl-click</button>

    <!-- Mouse buttons -->
    <div @click.right.prevent="openContextMenu">…</div>
</template>

Key aliases include .enter, .tab, .delete (Delete and Backspace), .esc, .space, .up, .down, .left and .right. Any KeyboardEvent.key value works in kebab-case, for example @keyup.page-down.

Two-way binding with v-model

v-model keeps a form field and a piece of state in sync in both directions. It's shorthand for a value binding plus an input listener:

<!-- Illustrative: what v-model expands to -->
<!-- This… -->
<input v-model="title">

<!-- …is equivalent to this -->
<input :value="title" @input="title = $event.target.value">

Vue picks the right property and event for each element type:

Element Binds Listens to
<input type="text">, <textarea> value input
<input type="checkbox"> checked (or array membership) change
<input type="radio"> checked vs value change
<select> selected value (array with multiple) change
<script setup>
import { ref } from 'vue';

const task = ref({
    title: '',
    notes: '',
    priority: 'normal',
    tags: [],          // checkbox group → array
    urgent: false,     // single checkbox → boolean
    estimate: 1,
});
</script>

<template>
    <input v-model.trim="task.title">
    <textarea v-model="task.notes"></textarea>

    <label><input type="radio" value="low" v-model="task.priority"> Low</label>
    <label><input type="radio" value="normal" v-model="task.priority"> Normal</label>
    <label><input type="radio" value="high" v-model="task.priority"> High</label>

    <label><input type="checkbox" value="frontend" v-model="task.tags"> Frontend</label>
    <label><input type="checkbox" value="backend" v-model="task.tags"> Backend</label>
    <label><input type="checkbox" value="devops" v-model="task.tags"> DevOps</label>

    <label><input type="checkbox" v-model="task.urgent"> Urgent</label>

    <select v-model.number="task.estimate">
        <option :value="1">1 hour</option>
        <option :value="4">Half day</option>
        <option :value="8">1 day</option>
    </select>

    <pre>{{ JSON.stringify(task, null, 2) }}</pre>
</template>

Every control below is bound to one reactive object. Watch the JSON update as you type:

v-model modifiers

Modifier Effect Typical use
.trim Strips whitespace from both ends Names, titles, emails
.number Casts to a number with parseFloat (falls back to the string) Quantities, estimates
.lazy Syncs on change instead of input (on blur or Enter) Expensive validation, search on commit

v-model ignores the value, checked and selected attributes in your HTML. The JavaScript state is always the single source of truth, so set initial values in ref(), not in markup.

Putting it together: the "Add task" form

Here is the complete form for the Task board, with validation, keyboard support and a list that reacts to the form:

<script setup>
import { ref, computed } from 'vue';

const tasks = ref([{ id: 1, title: 'Set up Vite', priority: 'normal', done: false }]);
const form = ref({ title: '', priority: 'normal' });
const submitted = ref(false);

const errors = computed(() => {
    const e = {};
    if (!form.value.title) e.title = 'A title is required.';
    else if (form.value.title.length < 3) e.title = 'At least 3 characters.';
    return e;
});
const isValid = computed(() => Object.keys(errors.value).length === 0);

function addTask() {
    submitted.value = true;
    if (!isValid.value) return;

    tasks.value.unshift({ id: Date.now(), ...form.value, done: false });
    form.value = { title: '', priority: 'normal' };
    submitted.value = false;
}

function remove(task) {
    tasks.value.splice(tasks.value.indexOf(task), 1);
}
</script>

<template>
    <form @submit.prevent="addTask" novalidate>
        <input
            v-model.trim="form.title"
            placeholder="What needs doing?"
            :aria-invalid="submitted && !!errors.title"
            @keyup.esc="form.title = ''"
        >
        <select v-model="form.priority">
            <option value="low">Low</option>
            <option value="normal">Normal</option>
            <option value="high">High</option>
        </select>
        <button type="submit">Add</button>
    </form>
    <p v-if="submitted && errors.title" class="error">{{ errors.title }}</p>

    <ul>
        <li v-for="task in tasks" :key="task.id">
            <input type="checkbox" v-model="task.done">
            {{ task.title }} <small>({{ task.priority }})</small>
            <button @click="remove(task)">×</button>
        </li>
    </ul>
</template>

Where validation lives. On the server, always. In a Laravel app that means a Form Request, because client-side checks can be bypassed. Client-side validation is a UX layer for instant feedback. A good pattern is to show Vue-side errors as the user types, then map Laravel's 422 JSON response ({ errors: { title: ['…'] } }) into the same errors object so both sources render identically.

Server-side validation: a real Laravel 422

Client-side checks are only half the story. This site is a Laravel app, and it exposes a small demo endpoint, POST /api/demo/tasks/validate, that validates a task with a real Form Request and never saves anything. This is the actual request class behind it:

// app/Http/Requests/StoreTaskRequest.php (the real code serving the demo below)
public function rules(): array
{
    return [
        'project_id' => ['required', 'integer', 'exists:projects,id'],
        'title'      => ['required', 'string', 'min:3', 'max:120'],
        'priority'   => ['required', Rule::enum(TaskPriority::class)], // low | medium | high
        'due_on'     => ['nullable', 'date', 'after_or_equal:today'],
    ];
}

When validation fails on a request that asks for JSON, Laravel responds with HTTP 422 and a predictable body. On success, this endpoint echoes the validated data:

{
    "message": "The title field must be at least 3 characters. (and 1 more error)",
    "errors": {
        "title": ["The title field must be at least 3 characters."],
        "due_on": ["The due date cannot be in the past."]
    }
}

The Vue side loads the project list from the API, posts the form, and maps errors straight onto the fields. Client-side checks are deliberately off here, so every message you see comes from Laravel:

<script setup>
import { ref, onMounted } from 'vue';

const projects = ref([]);
const form = ref({ project_id: '', title: '', priority: 'medium', due_on: '' });
const errors = ref({});        // { field: ['message', …] }, straight from Laravel
const status = ref('idle');    // idle | sending | valid | invalid | failed
const failure = ref('');
const result = ref(null);

const jsonHeaders = { 'Content-Type': 'application/json', Accept: 'application/json' };

onMounted(async () => {
    try {
        const res = await fetch('/api/demo/projects', { headers: jsonHeaders });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        projects.value = (await res.json()).data;
    } catch (e) {
        status.value = 'failed';
        failure.value = `Couldn't load projects (${e.message}).`;
    }
});

async function submit(payload = form.value) {
    status.value = 'sending';
    errors.value = {};
    try {
        const res = await fetch('/api/demo/tasks/validate', {
            method: 'POST',
            headers: jsonHeaders,   // Accept: application/json → 422 JSON, not a redirect
            body: JSON.stringify({ ...payload, due_on: payload.due_on || null }),
        });
        if (res.status === 422) {
            errors.value = (await res.json()).errors;
            status.value = 'invalid';
        } else if (res.ok) {
            result.value = (await res.json()).data;
            status.value = 'valid';
        } else {
            throw new Error(`HTTP ${res.status}`);
        }
    } catch (e) {
        status.value = 'failed';
        failure.value = `The demo API couldn't be reached (${e.message}).`;
    }
}
</script>

<template>
    <form @submit.prevent="submit()" novalidate>
        <select v-model.number="form.project_id">
            <option value="">Choose a project…</option>
            <option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
        </select>
        <p v-if="errors.project_id" class="error">{{ errors.project_id[0] }}</p>

        <input v-model.trim="form.title" placeholder="Title">
        <p v-if="errors.title" class="error">{{ errors.title[0] }}</p>

        <select v-model="form.priority">
            <option>low</option><option>medium</option><option>high</option>
        </select>

        <input type="date" v-model="form.due_on">
        <p v-if="errors.due_on" class="error">{{ errors.due_on[0] }}</p>

        <button :disabled="status === 'sending'">Validate on the server</button>
        <button type="button" @click="submit({ project_id: '', title: 'ab', priority: 'urgent', due_on: '2020-01-01' })">
            Send an invalid payload
        </button>
    </form>
</template>

Details worth noticing: the Accept: application/json header is what makes Laravel return a 422 JSON body instead of a redirect back with flashed session errors. The errors shape (field → array of messages) is the same for every Form Request, so one small helper can handle the whole app. In a session-authenticated SPA you'd normally use Axios with Sanctum's CSRF cookie. This demo endpoint is stateless and rate-limited, so plain fetch is enough. Priority is medium here rather than the Task board's normal, because the server's TaskPriority enum is the source of truth.

Key takeaways

  • @event="handler" passes the native event. @event="fn(arg, $event)" lets you pass arguments as well.
  • Event modifiers (.prevent, .stop, .self, .once) and key modifiers (.enter, .esc, .ctrl.exact) move DOM plumbing out of your methods.
  • v-model is sugar for :value + @input, and adapts to checkboxes, radios and selects automatically.
  • .trim, .number and .lazy handle the most common input clean-up.
  • Validate on the client for UX, but always validate on the server, and render Laravel's 422 errors through the same UI.