Saltar al contenido
schedule 12 min Vue

Eventos y formularios

Ya conoces @click de lecciones anteriores. Pero el sistema de eventos de Vue va mucho más allá — modificadores, eventos de teclado, manejo de formularios y patrones de validación que hacen que construir formularios interactivos sea pan comido.

Manejo de eventos en profundidad

Ya sabes que @click ejecuta una función cuando haces clic. Pero puedes hacer mucho más: pasar expresiones inline, acceder al objeto del evento nativo, y usar otros eventos del ratón.

Eventos.vue
<script setup>
import { ref } from 'vue'

const contador = ref(0)
const mensaje = ref('')
const hover = ref(false)

// Método que recibe el evento nativo
const handleClick = (event) => {
    mensaje.value = `Clic en posición: ${event.clientX}, ${event.clientY}`
}

// Método con parámetro personalizado + evento
const sumar = (cantidad, event) => {
    contador.value += cantidad
    console.log('Tipo de evento:', event.type)
}
</script>

<template>
    <!-- Método simple -->
    <button @click="contador++">Contador: {{ contador }}</button>

    <!-- Método con argumento -->
    <button @click="sumar(5, $event)">+5</button>

    <!-- Acceder al evento nativo con $event -->
    <div @click="handleClick($event)" style="padding: 20px; background: #f0f0f0">
        Haz clic aquí — {{ mensaje }}
    </div>

    <!-- Otros eventos del ratón -->
    <div
        @mouseenter="hover = true"
        @mouseleave="hover = false"
        @dblclick="mensaje = '¡Doble clic!'"
        :style="{ background: hover ? '#4FC08D' : '#ccc', padding: '20px', transition: '0.3s' }"
    >
        {{ hover ? '¡Estoy encima!' : 'Pasa el ratón por aquí' }}
    </div>
</template>

$event es una variable especial que Vue inyecta en los manejadores inline. Te da acceso al evento nativo del navegador, con toda su información (posición del clic, tecla presionada, elemento objetivo, etc.).

Modificadores de eventos

En JavaScript vanilla, escribes event.preventDefault() o event.stopPropagation() dentro de tus funciones. Vue te ofrece modificadores que hacen esto directamente en el template — tu código queda más limpio y declarativo.

Modificadores.vue
<script setup>
import { ref } from 'vue'

const formData = ref({ email: '' })
const enviado = ref(false)
const clics = ref(0)

const enviarFormulario = () => {
    enviado.value = true
    console.log('Formulario enviado:', formData.value)
}

const handleClickInterno = () => {
    clics.value++
}
</script>

<template>
    <!-- .prevent — equivale a event.preventDefault() -->
    <!-- EL MÁS COMÚN: evita que el form recargue la página -->
    <form @submit.prevent="enviarFormulario">
        <input v-model="formData.email" type="email" placeholder="Tu email">
        <button type="submit">Enviar</button>
    </form>

    <!-- .stop — equivale a event.stopPropagation() -->
    <!-- El clic en el botón NO se propaga al div padre -->
    <div @click="alert('clic en div padre')">
        <button @click.stop="handleClickInterno">
            Clics: {{ clics }} (no se propaga al padre)
        </button>
    </div>

    <!-- .once — el evento solo se dispara una vez -->
    <button @click.once="alert('¡Solo una vez!')">
        Haz clic (solo funciona la primera vez)
    </button>

    <!-- .self — solo si el target es el propio elemento -->
    <div @click.self="alert('clic en el div, no en hijos')" style="padding: 20px; background: #eee">
        <button>Hacer clic aquí NO dispara el evento del div</button>
    </div>

    <!-- Encadenar modificadores -->
    <a href="https://ejemplo.com" @click.stop.prevent="alert('Link bloqueado')">
        Este link no navega ni propaga el evento
    </a>
</template>

Tip: @submit.prevent es probablemente el modificador que más usarás en tu vida con Vue. Cada vez que manejes un formulario, lo necesitarás para evitar que la página se recargue.

Modificadores de teclado

Vue proporciona alias para las teclas más comunes, lo que hace que manejar atajos de teclado sea muy intuitivo.

Teclado.vue
<script setup>
import { ref } from 'vue'

const busqueda = ref('')
const resultados = ref([])
const historial = ref([])

const buscar = () => {
    if (busqueda.value.trim()) {
        historial.value.push(busqueda.value)
        resultados.value = [`Resultado para "${busqueda.value}"...`]
    }
}

const limpiar = () => {
    busqueda.value = ''
    resultados.value = []
}
</script>

<template>
    <!-- @keyup.enter — se dispara al presionar Enter -->
    <input
        v-model="busqueda"
        @keyup.enter="buscar"
        @keyup.escape="limpiar"
        placeholder="Buscar... (Enter para buscar, Esc para limpiar)"
    >

    <!-- Otras teclas disponibles -->
    <!-- @keyup.tab, @keyup.delete, @keyup.space -->
    <!-- @keyup.up, @keyup.down, @keyup.left, @keyup.right -->

    <!-- Combinaciones de teclas con modificadores de sistema -->
    <!-- @keydown.ctrl.s — Ctrl + S -->
    <!-- @keydown.alt.enter — Alt + Enter -->
    <!-- @keydown.shift.tab — Shift + Tab -->

    <!-- .exact — solo se dispara con ESA combinación exacta -->
    <!-- Ctrl+Click (sin Shift, sin Alt) -->
    <button @click.ctrl.exact="alert('Solo Ctrl+Click')">
        Ctrl + Click (exacto)
    </button>

    <ul>
        <li v-for="resultado in resultados" :key="resultado">
            {{ resultado }}
        </li>
    </ul>

    <p v-if="historial.length">
        Historial: {{ historial.join(', ') }}
    </p>
</template>

El modificador .exact es útil cuando quieres asegurarte de que solo esa combinación específica dispara el evento. Sin .exact, @click.ctrl también se dispararía con Ctrl+Shift+Click.

v-model en profundidad

Ya usaste v-model con inputs de texto. Pero funciona con todos los tipos de campos de formulario, y tiene modificadores propios que te ahorran trabajo.

Inputs de texto y textarea

TextInputs.vue
<script setup>
import { ref } from 'vue'

const nombre = ref('')
const biografia = ref('')
</script>

<template>
    <!-- Input de texto -->
    <input v-model="nombre" placeholder="Tu nombre">
    <p>Hola, {{ nombre || 'desconocido' }}</p>

    <!-- Textarea -->
    <textarea v-model="biografia" placeholder="Cuéntanos sobre ti..."></textarea>
    <p>{{ biografia.length }} caracteres</p>
</template>

Checkboxes

Checkboxes.vue
<script setup>
import { ref } from 'vue'

// Un solo checkbox — booleano
const aceptaTerminos = ref(false)

// Múltiples checkboxes — array
const extras = ref([])
</script>

<template>
    <!-- Checkbox booleano -->
    <label>
        <input type="checkbox" v-model="aceptaTerminos">
        Acepto los términos y condiciones
    </label>
    <p>Términos: {{ aceptaTerminos ? 'Aceptados' : 'No aceptados' }}</p>

    <!-- Checkboxes múltiples (mismo v-model, diferente value) -->
    <label>
        <input type="checkbox" v-model="extras" value="leche_extra">
        Leche extra
    </label>
    <label>
        <input type="checkbox" v-model="extras" value="canela">
        Canela
    </label>
    <label>
        <input type="checkbox" v-model="extras" value="crema">
        Crema batida
    </label>
    <p>Extras seleccionados: {{ extras }}</p>
    <!-- Resultado: ["leche_extra", "canela"] -->
</template>

Radio buttons

RadioButtons.vue
<script setup>
import { ref } from 'vue'

const tamano = ref('mediano')
</script>

<template>
    <label>
        <input type="radio" v-model="tamano" value="pequeno"> Pequeño
    </label>
    <label>
        <input type="radio" v-model="tamano" value="mediano"> Mediano
    </label>
    <label>
        <input type="radio" v-model="tamano" value="grande"> Grande
    </label>
    <p>Tamaño: {{ tamano }}</p>
</template>

Select

SelectInput.vue
<script setup>
import { ref } from 'vue'

// Select simple
const ciudad = ref('')

// Select múltiple
const ingredientes = ref([])
</script>

<template>
    <!-- Select simple -->
    <select v-model="ciudad">
        <option value="" disabled>Selecciona una ciudad</option>
        <option value="madrid">Madrid</option>
        <option value="barcelona">Barcelona</option>
        <option value="valencia">Valencia</option>
    </select>
    <p>Ciudad: {{ ciudad || 'No seleccionada' }}</p>

    <!-- Select múltiple (mantén Ctrl/Cmd para seleccionar varios) -->
    <select v-model="ingredientes" multiple>
        <option value="cafe">Café</option>
        <option value="leche">Leche</option>
        <option value="azucar">Azúcar</option>
        <option value="chocolate">Chocolate</option>
    </select>
    <p>Ingredientes: {{ ingredientes }}</p>
</template>

Modificadores de v-model

Vue incluye tres modificadores que transforman el valor automáticamente:

VModelModifiers.vue
<script setup>
import { ref } from 'vue'

const nombre = ref('')
const edad = ref(0)
const comentario = ref('')
</script>

<template>
    <!-- .trim — elimina espacios al inicio y final -->
    <input v-model.trim="nombre" placeholder="Nombre (sin espacios extra)">
    <p>"{{ nombre }}"</p>

    <!-- .number — convierte el valor a número automáticamente -->
    <input v-model.number="edad" type="number" placeholder="Edad">
    <p>Edad: {{ edad }} (tipo: {{ typeof edad }})</p>
    <!-- Sin .number, edad sería un string "25" en vez de number 25 -->

    <!-- .lazy — actualiza en el evento "change" en vez de "input" -->
    <!-- Es decir, se actualiza al salir del campo, no al escribir cada letra -->
    <input v-model.lazy="comentario" placeholder="Se actualiza al salir del campo">
    <p>Comentario: {{ comentario }}</p>
</template>

El modificador .lazy cambia el evento que escucha v-model: en vez de actualizarse con cada tecla (evento input), se actualiza cuando el campo pierde el foco o presionas Enter (evento change). Es útil para campos donde no necesitas reactividad instantánea y quieres evitar re-renders innecesarios.

Construyendo un formulario completo

Vamos a poner todo junto creando un formulario de pedido para Café Estelar. Combina inputs de texto, select, textarea, checkbox y manejo del submit.

PedidoCafe.vue
<script setup>
import { ref, reactive } from 'vue'

const formulario = reactive({
    nombre: '',
    email: '',
    telefono: '',
    tipoPedido: '',
    peticionesEspeciales: '',
    aceptaTerminos: false
})

const enviado = ref(false)
const pedidoFinal = ref(null)

const tiposPedido = [
    { value: 'espresso', label: 'Espresso Estelar' },
    { value: 'latte', label: 'Latte Nebulosa' },
    { value: 'cappuccino', label: 'Cappuccino Cósmico' },
    { value: 'mocha', label: 'Mocha Meteorito' },
    { value: 'frio', label: 'Café Frío Orbital' }
]

const enviarPedido = () => {
    // Guardamos una copia del pedido
    pedidoFinal.value = { ...formulario }
    enviado.value = true
}
</script>

<template>
    <div v-if="!enviado">
        <h2>Pedido — Café Estelar</h2>

        <form @submit.prevent="enviarPedido">
            <div>
                <label>Nombre</label>
                <input v-model.trim="formulario.nombre" type="text" placeholder="Tu nombre">
            </div>

            <div>
                <label>Email</label>
                <input v-model.trim="formulario.email" type="email" placeholder="[email protected]">
            </div>

            <div>
                <label>Teléfono</label>
                <input v-model.trim="formulario.telefono" type="tel" placeholder="+34 600 000 000">
            </div>

            <div>
                <label>Tipo de pedido</label>
                <select v-model="formulario.tipoPedido">
                    <option value="" disabled>Selecciona tu café</option>
                    <option
                        v-for="tipo in tiposPedido"
                        :key="tipo.value"
                        :value="tipo.value"
                    >
                        {{ tipo.label }}
                    </option>
                </select>
            </div>

            <div>
                <label>Peticiones especiales</label>
                <textarea
                    v-model.trim="formulario.peticionesEspeciales"
                    placeholder="¿Alguna petición especial? (leche de avena, sin azúcar...)"
                    rows="3"
                ></textarea>
            </div>

            <div>
                <label>
                    <input type="checkbox" v-model="formulario.aceptaTerminos">
                    Acepto los términos del servicio estelar
                </label>
            </div>

            <button type="submit">Enviar pedido</button>
        </form>

        <!-- Vista previa del estado reactivo -->
        <pre>{{ formulario }}</pre>
    </div>

    <div v-else>
        <h2>¡Pedido recibido!</h2>
        <p>Gracias, {{ pedidoFinal.nombre }}. Tu {{ pedidoFinal.tipoPedido }} está en camino.</p>
        <button @click="enviado = false">Nuevo pedido</button>
    </div>
</template>

Fíjate cómo reactive() es perfecto para agrupar todos los campos del formulario en un solo objeto. Usamos @submit.prevent para evitar la recarga de página, y el <pre> al final nos permite ver el estado reactivo en tiempo real mientras escribimos — muy útil durante el desarrollo.

Validación básica

Un formulario sin validación es como un cohete sin sistema de navegación. Vamos a añadir validación simple usando un objeto reactivo de errores y computed para verificar si el formulario es válido.

FormularioValidado.vue
<script setup>
import { ref, reactive, computed } from 'vue'

const formulario = reactive({
    nombre: '',
    email: '',
    tipoPedido: '',
    aceptaTerminos: false
})

const errores = reactive({
    nombre: '',
    email: '',
    tipoPedido: '',
    aceptaTerminos: ''
})

const enviado = ref(false)
const intentoEnvio = ref(false)

// Validar un campo individual
const validarCampo = (campo) => {
    switch (campo) {
        case 'nombre':
            errores.nombre = formulario.nombre.trim().length < 2
                ? 'El nombre debe tener al menos 2 caracteres'
                : ''
            break
        case 'email':
            const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
            errores.email = !emailRegex.test(formulario.email)
                ? 'Introduce un email válido'
                : ''
            break
        case 'tipoPedido':
            errores.tipoPedido = !formulario.tipoPedido
                ? 'Selecciona un tipo de pedido'
                : ''
            break
        case 'aceptaTerminos':
            errores.aceptaTerminos = !formulario.aceptaTerminos
                ? 'Debes aceptar los términos'
                : ''
            break
    }
}

// ¿Es válido todo el formulario?
const esValido = computed(() => {
    return formulario.nombre.trim().length >= 2
        && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formulario.email)
        && formulario.tipoPedido !== ''
        && formulario.aceptaTerminos
})

const enviarFormulario = () => {
    intentoEnvio.value = true

    // Validar todos los campos
    validarCampo('nombre')
    validarCampo('email')
    validarCampo('tipoPedido')
    validarCampo('aceptaTerminos')

    if (esValido.value) {
        enviado.value = true
    }
}
</script>

<template>
    <form @submit.prevent="enviarFormulario" v-if="!enviado">
        <div>
            <label>Nombre</label>
            <input
                v-model.trim="formulario.nombre"
                @blur="validarCampo('nombre')"
                :class="{ 'input-error': errores.nombre && intentoEnvio }"
                placeholder="Tu nombre"
            >
            <span v-if="errores.nombre && intentoEnvio" class="error">
                {{ errores.nombre }}
            </span>
        </div>

        <div>
            <label>Email</label>
            <input
                v-model.trim="formulario.email"
                @blur="validarCampo('email')"
                :class="{ 'input-error': errores.email && intentoEnvio }"
                type="email"
                placeholder="[email protected]"
            >
            <span v-if="errores.email && intentoEnvio" class="error">
                {{ errores.email }}
            </span>
        </div>

        <div>
            <label>Tipo de pedido</label>
            <select
                v-model="formulario.tipoPedido"
                @change="validarCampo('tipoPedido')"
                :class="{ 'input-error': errores.tipoPedido && intentoEnvio }"
            >
                <option value="" disabled>Selecciona tu café</option>
                <option value="espresso">Espresso</option>
                <option value="latte">Latte</option>
                <option value="cappuccino">Cappuccino</option>
            </select>
            <span v-if="errores.tipoPedido && intentoEnvio" class="error">
                {{ errores.tipoPedido }}
            </span>
        </div>

        <div>
            <label>
                <input
                    type="checkbox"
                    v-model="formulario.aceptaTerminos"
                    @change="validarCampo('aceptaTerminos')"
                >
                Acepto los términos
            </label>
            <span v-if="errores.aceptaTerminos && intentoEnvio" class="error">
                {{ errores.aceptaTerminos }}
            </span>
        </div>

        <!-- Botón deshabilitado hasta que el formulario sea válido -->
        <button type="submit" :disabled="!esValido">
            {{ esValido ? 'Enviar pedido' : 'Completa el formulario' }}
        </button>
    </form>

    <div v-else>
        <h2>¡Pedido confirmado!</h2>
        <p>Gracias, {{ formulario.nombre }}.</p>
    </div>
</template>

Los puntos clave de este patrón de validación:

  • Errores como objeto reactivo — cada campo tiene su propio mensaje de error.
  • Validación en @blur — los errores aparecen cuando el usuario sale del campo, no mientras escribe.
  • intentoEnvio — solo mostramos errores después del primer intento de envío, para no asustar al usuario antes de que empiece.
  • computed para esValido — se recalcula automáticamente cuando cualquier campo cambia, perfecto para habilitar/deshabilitar el botón.
  • :disabled — el botón de enviar se deshabilita hasta que todo esté correcto.

Resumen

  • Eventos@click, @dblclick, @mouseenter, @mouseleave, con acceso al evento nativo vía $event.
  • Modificadores de evento.prevent, .stop, .once, .self. Se pueden encadenar: @click.stop.prevent.
  • Teclas@keyup.enter, @keyup.escape, combinaciones con .ctrl, .alt, .shift, y .exact para coincidencia exacta.
  • v-model — funciona con text, textarea, checkbox (booleano/array), radio, select (simple/múltiple).
  • Modificadores de v-model.trim (limpia espacios), .number (convierte a número), .lazy (actualiza en change en vez de input).
  • Validación básica — objeto de errores reactivo + computed para estado global + v-if para mostrar mensajes + :disabled en el botón.
code

Formulario de Reserva — Café Estelar

Medio schedule 25 min

Construye un formulario de reserva para Café Estelar con validación completa. El componente debe incluir:

  • Campos del formulario:
    • nombre (text input) — obligatorio, mínimo 2 caracteres.
    • email (email input) — obligatorio, debe tener formato de email válido.
    • fecha (date input) — obligatoria, no puede ser una fecha pasada.
    • comensales (select 1-10) — obligatorio.
    • peticionesEspeciales (textarea) — opcional.
    • aceptaTerminos (checkbox) — obligatorio.
  • Validación:
    • Todos los campos obligatorios deben estar llenos.
    • El email debe tener un formato válido (usa una regex simple).
    • La fecha no puede ser anterior a hoy (compara con new Date()).
    • Muestra mensajes de error inline debajo de cada campo inválido con v-if.
  • Botón de enviar:
    • Deshabilitado (:disabled) hasta que el formulario sea válido.
    • Usa un computed para calcular si todo es válido.
  • Confirmación: después de enviar, oculta el formulario y muestra un mensaje de confirmación con los datos de la reserva.
lightbulb Pistas

Usa reactive() para agrupar los campos del formulario y otro reactive() para los errores. Para validar la fecha, puedes convertirla a objeto Date y compararla: new Date(formulario.fecha) < new Date(new Date().toISOString().split('T')[0]). Para el select de comensales, genera las opciones con v-for="n in 10" y usa :value="n". Recuerda usar @submit.prevent en el formulario y @blur en los inputs para validar al salir del campo.

Newsletter

Recibe nuevos cursos, actualizaciones, artículos del blog y promociones en tu correo.