En esta página

Props, emits y defineModel()

12 min lectura TextoCap. 2 — Reactividad

Comunicación entre componentes en Vue

En Vue, los datos fluyen de una manera específica: de padre a hijo mediante props y de hijo a padre mediante emits. Este flujo unidireccional hace que la aplicación sea predecible y fácil de depurar.

Padre ──props──► Hijo
Padre ◄──emit── Hijo

Para datos que necesitan compartirse entre componentes sin relación directa (hermanos, componentes distantes), se usa provide/inject o Pinia.

defineProps\() — declarar props con TypeScript

La forma recomendada de declarar props es con la sintaxis de genérico de TypeScript:

<script setup lang="ts">
const props = defineProps<{
  titulo: string           // Requerido
  descripcion?: string     // Opcional (undefined si no se pasa)
  cantidad: number
  activo?: boolean
  variante?: 'primary' | 'secondary' | 'danger'
  tags?: string[]
}>()

// Acceso: props.titulo, props.cantidad, etc.
console.log(props.titulo)
</script>

withDefaults() para valores por defecto

<script setup lang="ts">
const { variante = 'primary', activo = true, tags = [] } = withDefaults(
  defineProps<{
    titulo: string
    variante?: 'primary' | 'secondary' | 'danger'
    activo?: boolean
    tags?: string[]
  }>(),
  {
    variante: 'primary',
    activo: true,
    tags: () => [],  // Función factory para arrays y objetos
  }
)
</script>

Vue 3.5 — Reactive Props Destructure

Vue 3.5 permite desestructurar directamente con valores por defecto, y el compilador mantiene la reactividad:

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

// Desestructuración con valores por defecto — reactiva en Vue 3.5
const { nombre, nivel = 1, activo = false } = defineProps<{
  nombre: string
  nivel?: number
  activo?: boolean
}>()

// 'nombre', 'nivel', 'activo' son reactivos — el computed funciona
const etiqueta = computed(() => `[${nivel}] ${nombre}`)
</script>

El compilador de Vue 3.5 transforma automáticamente los accesos a las variables desestructuradas en props.nombre, props.nivel, etc., manteniendo el rastreo reactivo.

defineEmits\() — declarar eventos tipados

Los emits declaran qué eventos puede emitir un componente hacia su padre:

<script setup lang="ts">
// Sintaxis de tupla tipada (Vue 3.3+)
const emit = defineEmits<{
  click: []                          // Sin argumentos
  cambio: [valor: string]            // Un argumento
  seleccion: [id: number, nombre: string]  // Múltiples argumentos
  'actualizar:valor': [valor: string] // Evento con :
}>()

// Uso
function manejarClic(): void {
  emit('click')
}

function actualizar(valor: string): void {
  emit('cambio', valor)
  emit('actualizar:valor', valor)
}
</script>

En el componente padre, se escuchan con @nombre-evento:

<template>
  <MiComponente
    @click="manejarClic"
    @cambio="manejarCambio"
    @seleccion="(id, nombre) => console.log(id, nombre)"
  />
</template>

v-model en componentes — el enfoque clásico

Antes de Vue 3.4, implementar v-model en componentes requería declarar manualmente el prop modelValue y emitir update:modelValue:

<!-- Componente hijo — enfoque clásico -->
<script setup lang="ts">
defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [valor: string] }>()
</script>

<template>
  <input
    :value="modelValue"
    @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
  />
</template>
<!-- Componente padre -->
<template>
  <MiInput v-model="texto" />
  <!-- Equivale a: :modelValue="texto" @update:modelValue="texto = $event" -->
</template>

defineModel() — la nueva API (Vue 3.4+)

defineModel() es la forma moderna y concisa de implementar v-model:

<script setup lang="ts">
// defineModel() declara automáticamente el prop y el emit
const modelo = defineModel<string>({ required: true })

// modelo.value es el valor actual
// Asignar a modelo.value emite el evento automáticamente
</script>

<template>
  <input v-model="modelo" />
  <!-- o -->
  <input
    :value="modelo"
    @input="modelo = ($event.target as HTMLInputElement).value"
  />
</template>

Múltiples v-model en un componente

Vue soporta múltiples v-model con nombres personalizados:

<!-- Componente hijo -->
<script setup lang="ts">
const nombre = defineModel<string>('nombre', { required: true })
const apellido = defineModel<string>('apellido', { default: '' })
</script>
<!-- Componente padre -->
<template>
  <EditorNombre
    v-model:nombre="usuario.nombre"
    v-model:apellido="usuario.apellido"
  />
</template>

Modificadores en defineModel()

Puedes acceder a los modificadores de v-model desde el componente:

<script setup lang="ts">
const [modelo, modificadores] = defineModel<string>()

// Si el padre usa v-model.trim, modificadores.trim === true
function actualizarValor(evento: Event): void {
  const valor = (evento.target as HTMLInputElement).value
  modelo.value = modificadores.trim ? valor.trim() : valor
}
</script>

Props booleanas — atajo de sintaxis

Vue tiene un comportamiento especial para props booleanas. Si el nombre del prop es el mismo que el atributo booleano HTML, Vue convierte el atributo a true sin necesidad de especificar el valor:

<!-- Si Boton tiene: disabled?: boolean -->
<Boton disabled />
<!-- equivale a -->
<Boton :disabled="true" />

Validación de props en runtime

Además de TypeScript, Vue puede validar props en tiempo de ejecución usando la sintaxis de objeto (útil para librerías):

const props = defineProps({
  edad: {
    type: Number,
    required: true,
    validator: (valor: number) => valor >= 0 && valor <= 120,
  },
  estado: {
    type: String as PropType<'activo' | 'inactivo'>,
    default: 'activo',
  }
})

Patrón de componente controlado

Un componente controlado es aquel cuyo estado es completamente controlado por el padre a través de props:

<!-- Controlado — el padre gestiona el estado -->
<script setup lang="ts">
const { valor, activo } = defineProps<{ valor: string; activo: boolean }>()
const emit = defineEmits<{ 'update:valor': [string]; 'update:activo': [boolean] }>()
</script>

Vs componente no controlado (gestiona su propio estado interno):

<!-- No controlado — gestiona su propio estado -->
<script setup lang="ts">
import { ref } from 'vue'
const valor = ref('')
const activo = ref(false)
</script>

defineModel() crea componentes controlados de forma elegante.


Práctica

  1. Componente de rating: Crea un componente Valoracion.vue que muestre 5 estrellas. Usa defineModel<number>() para el valor seleccionado. En el padre, muestra el valor actual y permite cambiarlo.
  2. Formulario controlado: Crea un formulario de contacto donde cada campo es un componente InputPersonalizado.vue con defineModel<string>(). El padre gestiona todos los valores y los envía en un objeto al submit.
  3. Múltiples v-model: Crea un componente RangoFechas.vue con v-model:inicio y v-model:fin para seleccionar un rango de fechas. El padre muestra los valores seleccionados.

En la siguiente lección aprenderemos a reutilizar lógica con composables, el patrón más poderoso de Vue 3.

defineModel() — la nueva forma de v-model en componentes
defineModel() fue estabilizado en Vue 3.4. Es açúcar sintáctico que elimina la necesidad de declarar un prop modelValue y emitir update:modelValue manualmente. El resultado es código mucho más conciso y fácil de leer.
Props son de solo lectura — nunca las mutes
Las props fluyen de padre a hijo y son de solo lectura en el componente hijo. Si necesitas modificar el valor de un prop localmente, copia el valor en una variable reactiva local con ref(prop.valor). Para comunicar cambios al padre, usa emits o defineModel().
Reactive Props Destructure en Vue 3.5
Antes de Vue 3.5, desestructurar props perdía la reactividad porque los valores eran copiados. Vue 3.5 introdujo Reactive Props Destructure: el compilador transforma la desestructuración para mantener el rastreo reactivo. Esto es seguro de usar en proyectos con Vue 3.5+.
<script setup lang="ts">
// defineModel() — Vue 3.4+ — para v-model en componentes
const modelo = defineModel<string>({ required: true })

// Props adicionales
const {
  etiqueta = 'Campo',
  placeholder = '',
  tipo = 'text',
  error = '',
} = defineProps<{
  etiqueta?: string
  placeholder?: string
  tipo?: 'text' | 'email' | 'password' | 'number'
  error?: string
}>()

// Emits tipados
const emit = defineEmits<{
  blur: [evento: FocusEvent]
  enter: [valor: string]
}>()

function manejarBlur(e: FocusEvent): void {
  emit('blur', e)
}

function manejarEnter(): void {
  emit('enter', modelo.value)
}
</script>

<template>
  <div class="campo">
    <label>
      {{ etiqueta }}
      <input
        :type="tipo"
        :value="modelo"
        :placeholder="placeholder"
        :class="{ 'error': error }"
        @input="modelo = ($event.target as HTMLInputElement).value"
        @blur="manejarBlur"
        @keyup.enter="manejarEnter"
      />
    </label>
    <span v-if="error" class="mensaje-error" role="alert">{{ error }}</span>
  </div>
</template>

<style scoped>
.campo { display: flex; flex-direction: column; gap: 0.25rem; }
input.error { border-color: red; }
.mensaje-error { color: red; font-size: 0.875rem; }
</style>
vue
<script setup lang="ts">
import { computed } from 'vue'

// Vue 3.5 — Reactive Props Destructure
// Los valores desestructurados SON reactivos (rastreados por el compilador)
const {
  nombre,
  apellido,
  activo = true,
} = defineProps<{
  nombre: string
  apellido: string
  activo?: boolean
}>()

// Esto SÍ funciona en Vue 3.5 — la desestructuración es reactiva
const nombreCompleto = computed(() => `${nombre} ${apellido}`)
const estado = computed(() => activo ? 'Activo' : 'Inactivo')
</script>

<template>
  <div :class="{ activo }">
    <p>{{ nombreCompleto }}</p>
    <span>{{ estado }}</span>
  </div>
</template>