En esta página
Formularios reactivos y v-model avanzado
v-model a fondo
v-model es açúcar sintáctico sobre :value + @input para inputs nativos. Pero el tipo de evento varía según el elemento:
| Elemento | Prop | Evento |
|---|---|---|
<input type="text"> |
:value |
@input |
<input type="checkbox"> |
:checked |
@change |
<input type="radio"> |
:checked |
@change |
<select> |
:value |
@change |
<textarea> |
:value |
@input |
Vue maneja estas diferencias automáticamente:
<script setup lang="ts">
import { ref } from 'vue'
const texto = ref('')
const activo = ref(false)
const opcionRadio = ref('a')
const seleccionMultiple = ref<string[]>([])
</script>
<template>
<!-- Text input -->
<input v-model="texto" />
<!-- Checkbox — enlaza con boolean -->
<input v-model="activo" type="checkbox" />
<!-- Radio buttons — enlaza con string -->
<input v-model="opcionRadio" type="radio" value="a" />
<input v-model="opcionRadio" type="radio" value="b" />
<!-- Select múltiple — enlaza con array -->
<select v-model="seleccionMultiple" multiple>
<option>Vue</option>
<option>React</option>
<option>Angular</option>
</select>
</template>Modificadores de v-model
.trim — eliminar espacios
<input v-model.trim="nombre" />
<!-- nombre nunca tendrá espacios al inicio o al final -->.number — convertir a número
<input v-model.number="edad" type="number" />
<!-- edad será siempre un número (o NaN si el campo está vacío) -->.lazy — actualizar en blur
<!-- Actualiza el ref solo cuando el input pierde el foco -->
<input v-model.lazy="busqueda" />Combinar modificadores:
<input v-model.trim.lazy="nombre" />
<!-- Elimina espacios Y solo actualiza en blur -->Checkbox con valores personalizados
Por defecto, un checkbox enlaza a true/false. Puedes personalizar los valores:
<script setup lang="ts">
import { ref } from 'vue'
const estado = ref<'activo' | 'inactivo'>('inactivo')
</script>
<template>
<input
v-model="estado"
type="checkbox"
true-value="activo"
false-value="inactivo"
/>
<p>Estado: {{ estado }}</p>
</template>Construir formularios complejos sin librerías
Para formularios simples, reactive() con validación manual es suficiente:
interface FormRegistro {
nombre: string
email: string
password: string
confirmar: string
}
function useFormRegistro() {
const datos = reactive<FormRegistro>({
nombre: '',
email: '',
password: '',
confirmar: '',
})
const errores = reactive<Partial<Record<keyof FormRegistro, string>>>({})
const tocados = reactive<Partial<Record<keyof FormRegistro, boolean>>>({})
function validar(campo: keyof FormRegistro): boolean {
tocados[campo] = true
switch (campo) {
case 'nombre':
errores.nombre = datos.nombre.trim().length >= 2
? undefined
: 'Mínimo 2 caracteres'
break
case 'email':
errores.email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(datos.email)
? undefined
: 'Email inválido'
break
case 'password':
errores.password = datos.password.length >= 8
? undefined
: 'Mínimo 8 caracteres'
if (tocados.confirmar) validar('confirmar')
break
case 'confirmar':
errores.confirmar = datos.confirmar === datos.password
? undefined
: 'Las contraseñas no coinciden'
break
}
return !errores[campo]
}
function validarTodo(): boolean {
const campos: (keyof FormRegistro)[] = ['nombre', 'email', 'password', 'confirmar']
return campos.every(validar)
}
function resetear(): void {
Object.assign(datos, { nombre: '', email: '', password: '', confirmar: '' })
Object.keys(errores).forEach(k => delete (errores as Record<string, unknown>)[k])
Object.keys(tocados).forEach(k => delete (tocados as Record<string, unknown>)[k])
}
return { datos, errores, tocados, validar, validarTodo, resetear }
}VeeValidate — validación declarativa
VeeValidate v4 es la librería de formularios más popular para Vue:
npm install vee-validate zod @vee-validate/zoduseForm() — gestión del formulario completo
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const schema = toTypedSchema(z.object({
nombre: z.string().min(2, 'Mínimo 2 caracteres'),
email: z.string().email('Email inválido'),
edad: z.number().min(18, 'Debe ser mayor de edad'),
}))
const { handleSubmit, defineField, errors, resetForm } = useForm({
validationSchema: schema,
initialValues: { nombre: '', email: '', edad: 0 },
})
// defineField crea el binding para v-model
const [nombre, nombreProps] = defineField('nombre')
const [email, emailProps] = defineField('email')
const onSubmit = handleSubmit((valores) => {
console.log('Válido:', valores) // TypeScript conoce los tipos exactos
})
</script>
<template>
<form @submit="onSubmit">
<input v-model="nombre" v-bind="nombreProps" />
<span v-if="errors.nombre">{{ errors.nombre }}</span>
<input v-model="email" v-bind="emailProps" type="email" />
<span v-if="errors.email">{{ errors.email }}</span>
<button type="submit">Enviar</button>
<button type="button" @click="resetForm()">Resetear</button>
</form>
</template>Accesibilidad en formularios
Un formulario accesible requiere:
<template>
<form @submit.prevent="enviar" novalidate aria-label="Formulario de contacto">
<div role="group">
<label for="nombre">
Nombre
<span aria-hidden="true">*</span>
</label>
<input
id="nombre"
v-model.trim="form.nombre"
type="text"
autocomplete="given-name"
required
:aria-required="true"
:aria-invalid="!!errores.nombre || undefined"
:aria-describedby="errores.nombre ? 'error-nombre' : undefined"
@blur="validarCampo('nombre')"
/>
<span
v-if="errores.nombre"
id="error-nombre"
role="alert"
aria-live="polite"
>
{{ errores.nombre }}
</span>
</div>
</form>
</template>Cargar y guardar datos del formulario
Patrón común para formularios de edición:
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
const { id } = defineProps<{ id: number }>()
const form = reactive({
nombre: '',
email: '',
cargando: false,
guardando: false,
})
onMounted(async () => {
form.cargando = true
const res = await fetch(`/api/usuarios/${id}`)
const datos = await res.json() as { nombre: string; email: string }
form.nombre = datos.nombre
form.email = datos.email
form.cargando = false
})
async function guardar(): Promise<void> {
form.guardando = true
await fetch(`/api/usuarios/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: form.nombre, email: form.email }),
})
form.guardando = false
}
</script>Práctica
- Formulario de contacto: Construye un formulario con nombre, email, asunto y mensaje. Valida en blur, muestra errores accesibles con
role="alert". Deshabilita el botón de envío hasta que todo sea válido. - Formulario multi-paso: Implementa un formulario de registro en 3 pasos (datos personales, credenciales, preferencias). Guarda el estado entre pasos con
reactive(). Valida cada paso antes de avanzar. - Integración con VeeValidate + Zod: Convierte el formulario de contacto para usar VeeValidate y Zod. Compara la experiencia con la validación manual.
En la siguiente lección aprenderemos a manejar datos asíncronos con composables y el componente Suspense.
novalidate en el form — usa tu propia validación
Añade el atributo novalidate al elemento form para desactivar la validación nativa del navegador. Esto te da control total sobre los mensajes de error y su presentación, permitiendo mostrarlos con los estilos de tu diseño.
VeeValidate + Zod — la combinación más popular
VeeValidate v4 se integra con Zod, Yup y Valibot mediante adaptadores. La integración con Zod (toTypedSchema) es la más popular porque Zod infiere los tipos TypeScript automáticamente desde el schema, eliminando la duplicación entre la validación de runtime y los tipos estáticos.
v-model.number no convierte siempre — verifica el tipo
El modificador .number de v-model convierte el valor a número cuando es posible, pero si el campo está vacío devuelve un string vacío. Al validar, usa Number.isFinite() o comprueba typeof para evitar bugs sutiles con NaN.
<script setup lang="ts">
import { reactive, computed } from 'vue'
interface FormDatos {
nombre: string
email: string
password: string
confirmar: string
rol: 'usuario' | 'editor'
aceptaTerminos: boolean
}
const form = reactive<FormDatos>({
nombre: '',
email: '',
password: '',
confirmar: '',
rol: 'usuario',
aceptaTerminos: false,
})
const errores = reactive<Partial<Record<keyof FormDatos, string>>>({})
function validarCampo(campo: keyof FormDatos): void {
switch (campo) {
case 'nombre':
errores.nombre = form.nombre.trim().length >= 2
? '' : 'El nombre debe tener al menos 2 caracteres'
break
case 'email':
errores.email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)
? '' : 'Ingresa un email válido'
break
case 'password':
errores.password = form.password.length >= 8
? '' : 'La contraseña debe tener al menos 8 caracteres'
break
case 'confirmar':
errores.confirmar = form.confirmar === form.password
? '' : 'Las contraseñas no coinciden'
break
}
}
const esValido = computed(() =>
form.nombre.trim().length >= 2 &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) &&
form.password.length >= 8 &&
form.confirmar === form.password &&
form.aceptaTerminos
)
function enviar(): void {
// Validar todos los campos
const campos: (keyof FormDatos)[] = ['nombre', 'email', 'password', 'confirmar']
campos.forEach(validarCampo)
if (!esValido.value) return
console.log('Enviando:', form)
}
</script>
<template>
<form @submit.prevent="enviar" novalidate>
<div>
<label for="nombre">Nombre</label>
<input
id="nombre"
v-model.trim="form.nombre"
@blur="validarCampo('nombre')"
:aria-describedby="errores.nombre ? 'error-nombre' : undefined"
:aria-invalid="!!errores.nombre"
/>
<span v-if="errores.nombre" id="error-nombre" role="alert">
{{ errores.nombre }}
</span>
</div>
<div>
<label>Rol</label>
<select v-model="form.rol">
<option value="usuario">Usuario</option>
<option value="editor">Editor</option>
</select>
</div>
<div>
<input
id="terminos"
v-model="form.aceptaTerminos"
type="checkbox"
/>
<label for="terminos">Acepto los términos</label>
</div>
<button type="submit" :disabled="!esValido">Registrarse</button>
</form>
</template>
<script setup lang="ts">
import { useForm, useField } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
// Definir el schema de validación con Zod
const schema = toTypedSchema(z.object({
email: z.string().email('Email inválido'),
password: z.string().min(8, 'Mínimo 8 caracteres'),
edad: z.number({ invalid_type_error: 'Debe ser un número' })
.min(18, 'Debes ser mayor de edad'),
}))
const { handleSubmit, errors, isSubmitting } = useForm({ validationSchema: schema })
const { value: email } = useField<string>('email')
const { value: password } = useField<string>('password')
const { value: edad } = useField<number>('edad')
const onSubmit = handleSubmit(async (valores) => {
// valores está completamente tipado según el schema
console.log('Datos válidos:', valores)
await new Promise(r => setTimeout(r, 1000)) // Simula API
})
</script>
<template>
<form @submit="onSubmit">
<div>
<label for="email">Email</label>
<input id="email" v-model="email" type="email" />
<span v-if="errors.email" role="alert">{{ errors.email }}</span>
</div>
<div>
<label for="password">Contraseña</label>
<input id="password" v-model="password" type="password" />
<span v-if="errors.password" role="alert">{{ errors.password }}</span>
</div>
<div>
<label for="edad">Edad</label>
<input id="edad" v-model.number="edad" type="number" />
<span v-if="errors.edad" role="alert">{{ errors.edad }}</span>
</div>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? 'Enviando...' : 'Enviar' }}
</button>
</form>
</template>
Inicia sesión para guardar tu progreso