En esta página

Formularios reactivos y v-model avanzado

14 min lectura TextoCap. 4 — Estado y datos

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/zod

useForm() — 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

  1. 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.
  2. 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.
  3. 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.
vue
<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>
vue
<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>