Formularios avanzados en Vue

Intermedio
Vuejs
Vuejs
Actualizado: 27/03/2026

Tipos de input con v-model

Vue permite utilizar v-model con todos los tipos de elementos de formulario HTML. Cada tipo de input tiene un comportamiento específico que conviene dominar para construir formularios completos y robustos.

Campos de texto, número y fecha

Los campos de texto, número y fecha son los más habituales en cualquier formulario:

<template>
  <div>
    <input v-model="nombre" type="text" placeholder="Nombre" />
    <input v-model.number="edad" type="number" placeholder="Edad" />
    <textarea v-model="bio" rows="4" placeholder="Biografía"></textarea>
    <input v-model="fechaNacimiento" type="date" />

    <p>Nombre: {{ nombre }}</p>
    <p>Edad: {{ edad }} (tipo: {{ typeof edad }})</p>
    <p>Bio: {{ bio }}</p>
    <p>Fecha: {{ fechaNacimiento }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const nombre = ref('')
const edad = ref<number>(0)
const bio = ref('')
const fechaNacimiento = ref('')
</script>

Checkbox individual y múltiple

Un checkbox individual devuelve un valor booleano. Cuando se enlazan múltiples checkboxes al mismo array, Vue gestiona automáticamente la adición y eliminación de valores:

<template>
  <div>
    <label>
      <input type="checkbox" v-model="aceptaTerminos" />
      Acepto los terminos
    </label>
    <p>Terminos aceptados: {{ aceptaTerminos }}</p>

    <h4>Intereses:</h4>
    <label v-for="opcion in opcionesIntereses" :key="opcion">
      <input type="checkbox" v-model="intereses" :value="opcion" />
      {{ opcion }}
    </label>
    <p>Seleccionados: {{ intereses }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const aceptaTerminos = ref(false)
const opcionesIntereses = ['Tecnologia', 'Deportes', 'Musica', 'Viajes']
const intereses = ref<string[]>([])
</script>

Radio buttons

Los radio buttons enlazados con v-model al mismo ref permiten seleccionar una unica opcion dentro de un grupo:

<template>
  <div>
    <label v-for="color in colores" :key="color">
      <input type="radio" v-model="colorFavorito" :value="color" />
      {{ color }}
    </label>
    <p>Color favorito: {{ colorFavorito }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const colores = ['Rojo', 'Verde', 'Azul', 'Amarillo']
const colorFavorito = ref('')
</script>

Select simple y múltiple

El elemento select puede funcionar en modo individual o múltiple. En modo múltiple, v-model se enlaza a un array:

<template>
  <div>
    <select v-model="pais">
      <option value="" disabled>Selecciona un pais</option>
      <option value="es">Espana</option>
      <option value="mx">Mexico</option>
      <option value="ar">Argentina</option>
    </select>
    <p>Pais: {{ pais }}</p>

    <select v-model="idiomas" multiple>
      <option value="es">Espanol</option>
      <option value="en">Ingles</option>
      <option value="fr">Frances</option>
      <option value="de">Aleman</option>
    </select>
    <p>Idiomas: {{ idiomas }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const pais = ref('')
const idiomas = ref<string[]>([])
</script>

Modificadores de v-model

Vue ofrece tres modificadores que alteran el comportamiento de v-model de forma declarativa.

.lazy

Por defecto, v-model sincroniza el valor en cada evento input. Con .lazy, la sincronizacion ocurre en el evento change, es decir, cuando el campo pierde el foco:

<input v-model.lazy="busqueda" type="text" placeholder="Se actualiza al perder el foco" />

.number

El modificador .number convierte automáticamente el valor a tipo numérico usando parseFloat. Si la conversión falla, devuelve el valor original:

<input v-model.number="precio" type="text" placeholder="Solo números" />

.trim

El modificador .trim elimina automáticamente los espacios en blanco al inicio y final del valor introducido:

<input v-model.trim="email" type="text" placeholder="Sin espacios extra" />

Se pueden combinar varios modificadores en el mismo input: v-model.lazy.trim="campo".

Generacion dinamica de formularios

Cuando los campos del formulario provienen de una configuración o de una API, se pueden generar de forma dinámica a partir de un esquema de datos:

<template>
  <form @submit.prevent="enviar">
    <div v-for="campo in esquema" :key="campo.nombre">
      <label :for="campo.nombre">{{ campo.etiqueta }}</label>

      <input
        v-if="campo.tipo === 'text' || campo.tipo === 'email' || campo.tipo === 'number'"
        :id="campo.nombre"
        :type="campo.tipo"
        v-model="formulario[campo.nombre]"
      />

      <textarea
        v-else-if="campo.tipo === 'textarea'"
        :id="campo.nombre"
        v-model="formulario[campo.nombre]"
      ></textarea>

      <select
        v-else-if="campo.tipo === 'select'"
        :id="campo.nombre"
        v-model="formulario[campo.nombre]"
      >
        <option v-for="op in campo.opciones" :key="op" :value="op">{{ op }}</option>
      </select>
    </div>
    <button type="submit">Enviar</button>
  </form>
</template>

<script setup lang="ts">
import { reactive } from 'vue'

interface CampoEsquema {
  nombre: string
  etiqueta: string
  tipo: 'text' | 'email' | 'number' | 'textarea' | 'select'
  opciones?: string[]
}

const esquema: CampoEsquema[] = [
  { nombre: 'nombre', etiqueta: 'Nombre', tipo: 'text' },
  { nombre: 'email', etiqueta: 'Email', tipo: 'email' },
  { nombre: 'edad', etiqueta: 'Edad', tipo: 'number' },
  { nombre: 'mensaje', etiqueta: 'Mensaje', tipo: 'textarea' },
  { nombre: 'rol', etiqueta: 'Rol', tipo: 'select', opciones: ['Admin', 'Editor', 'Lector'] }
]

const formulario = reactive<Record<string, string | number>>({})

function enviar() {
  console.log('Datos:', { ...formulario })
}
</script>

Componentes de input reutilizables con defineModel

A partir de Vue 3.4, defineModel simplifica la creación de componentes de formulario reutilizables. defineModel crea automáticamente una prop y el evento de actualización correspondiente, eliminando la necesidad de declarar props y emits manualmente:

<!-- FormInput.vue -->
<template>
  <div class="form-input">
    <label :for="id">{{ label }}</label>
    <input :id="id" :type="type" v-model="model" :placeholder="placeholder" />
  </div>
</template>

<script setup lang="ts">
const model = defineModel<string>({ required: true })

defineProps<{
  label: string
  id: string
  type?: string
  placeholder?: string
}>()
</script>

Uso desde el componente padre:

<template>
  <FormInput v-model="nombre" label="Nombre" id="nombre" placeholder="Tu nombre" />
  <FormInput v-model="email" label="Email" id="email" type="email" placeholder="tu@email.com" />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import FormInput from './FormInput.vue'

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

Manejo de archivos: FileReader y FormData

Los campos de tipo file no soportan v-model directamente. Se manejan mediante el evento @change y las APIs nativas FileReader y FormData:

<template>
  <div>
    <input type="file" accept="image/*" @change="onFileChange" />
    <img v-if="previewUrl" :src="previewUrl" alt="Preview" width="200" />
    <button v-if="archivo" @click="enviarArchivo">Subir archivo</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const archivo = ref<File | null>(null)
const previewUrl = ref<string>('')

function onFileChange(event: Event) {
  const input = event.target as HTMLInputElement
  if (!input.files?.length) return

  archivo.value = input.files[0]

  const reader = new FileReader()
  reader.onload = (e) => {
    previewUrl.value = e.target?.result as string
  }
  reader.readAsDataURL(archivo.value)
}

async function enviarArchivo() {
  if (!archivo.value) return

  const formData = new FormData()
  formData.append('imagen', archivo.value)

  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
  })
  const resultado = await response.json()
  console.log('Subida completada:', resultado)
}
</script>

Formularios multi-paso (wizard)

Los formularios multipaso dividen un formulario largo en secciones más manejables. Se controla la navegación con un contador de paso reactivo:

<template>
  <div class="wizard">
    <div class="progress-bar">
      <div
        v-for="n in totalPasos"
        :key="n"
        class="step"
        :class="{ active: n <= pasoActual, completed: n < pasoActual }"
      >
        {{ n }}
      </div>
    </div>

    <form @submit.prevent="enviar">
      <div v-if="pasoActual === 1">
        <h3>Datos personales</h3>
        <input v-model="datos.nombre" type="text" placeholder="Nombre" />
        <input v-model="datos.email" type="email" placeholder="Email" />
      </div>

      <div v-if="pasoActual === 2">
        <h3>Preferencias</h3>
        <label v-for="tema in temas" :key="tema">
          <input type="checkbox" v-model="datos.temas" :value="tema" /> {{ tema }}
        </label>
      </div>

      <div v-if="pasoActual === 3">
        <h3>Revision</h3>
        <p><strong>Nombre:</strong> {{ datos.nombre }}</p>
        <p><strong>Email:</strong> {{ datos.email }}</p>
        <p><strong>Temas:</strong> {{ datos.temas.join(', ') }}</p>
      </div>

      <div class="botones">
        <button type="button" v-if="pasoActual > 1" @click="pasoActual--">Anterior</button>
        <button type="button" v-if="pasoActual < totalPasos" @click="pasoActual++">Siguiente</button>
        <button type="submit" v-if="pasoActual === totalPasos">Enviar</button>
      </div>
    </form>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'

const pasoActual = ref(1)
const totalPasos = 3
const temas = ['Frontend', 'Backend', 'DevOps', 'Data Science']

const datos = reactive({
  nombre: '',
  email: '',
  temas: [] as string[]
})

function enviar() {
  console.log('Formulario enviado:', { ...datos })
}
</script>

Gestion del estado del formulario

En formularios complejos conviene rastrear el estado del formulario: si se ha modificado (dirty), si se esta enviando (loading) o si ya se envio (submitted):

<template>
  <form @submit.prevent="enviar">
    <input v-model="nombre" type="text" @input="marcarModificado" placeholder="Nombre" />
    <input v-model="email" type="email" @input="marcarModificado" placeholder="Email" />

    <p v-if="isDirty" class="aviso">Tienes cambios sin guardar</p>

    <button type="submit" :disabled="isLoading || isSubmitted">
      <span v-if="isLoading">Enviando...</span>
      <span v-else-if="isSubmitted">Enviado correctamente</span>
      <span v-else>Enviar</span>
    </button>

    <button type="button" v-if="isDirty && !isLoading" @click="resetear">Resetear</button>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const nombre = ref('')
const email = ref('')
const isDirty = ref(false)
const isLoading = ref(false)
const isSubmitted = ref(false)

function marcarModificado() {
  isDirty.value = true
  isSubmitted.value = false
}

function resetear() {
  nombre.value = ''
  email.value = ''
  isDirty.value = false
  isSubmitted.value = false
}

async function enviar() {
  isLoading.value = true
  try {
    await new Promise(resolve => setTimeout(resolve, 1500))
    isSubmitted.value = true
    isDirty.value = false
  } finally {
    isLoading.value = false
  }
}
</script>

Este patron permite informar al usuario del estado exacto del formulario en cada momento y evitar envios duplicados o perdida accidental de datos no guardados.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Vuejs es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Vuejs

Explora más contenido relacionado con Vuejs y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje