Vue 3 con Composition API es, a día de 2026, uno de los frameworks de frontend más elegantes y productivos que existen. Si vienes de React, de Angular, o simplemente estás empezando en el mundo del frontend, Vue 3 tiene una curva de aprendizaje gentil y un resultado final que da gusto escribir. Esta guía te lleva desde instalar Vue hasta construir aplicaciones reales con estado reactivo, componentes reutilizables, composables y el ecosistema completo.
¿Por qué Vue 3 y no Vue 2?
Vue 2 llegó al final de su vida útil en diciembre de 2023. Vue 3 no es solo una actualización: es una reescritura completa que introduce el sistema de reactividad basado en Proxy, la Composition API, mejor soporte para TypeScript, mejoras de rendimiento significativas y un sistema de compilación más eficiente. Si empiezas hoy con Vue, empieza directamente con Vue 3 y la Composition API.
La Composition API es la forma moderna de escribir componentes en Vue 3. Permite organizar el código por lógica en lugar de por opciones (data, methods, computed…), lo que hace que los componentes grandes sean mucho más fáciles de mantener y que la lógica sea mucho más fácil de reutilizar a través de composables.
Crear tu primer proyecto Vue 3
La forma oficial de crear un proyecto Vue 3 es usando create-vue, el scaffolding oficial que usa Vite como bundler:
# Crear nuevo proyecto Vue 3
npm create vue@latest
# El asistente te preguntará:
# Nombre del proyecto: mi-app-vue
# TypeScript: Sí (muy recomendado)
# JSX: No (opcional)
# Vue Router: Sí
# Pinia: Sí (gestión de estado)
# Vitest: Sí (testing)
# ESLint + Prettier: Sí
# Instalar dependencias y arrancar
cd mi-app-vue
npm install
npm run dev
En menos de un minuto tienes un proyecto listo con TypeScript, router, gestión de estado y testing configurados. Vite hace que el servidor de desarrollo arranque en milisegundos y que el Hot Module Replacement sea prácticamente instantáneo.
Entender la estructura de un componente Vue 3
Los componentes de Vue se escriben en archivos .vue llamados Single File Components (SFC). Tienen tres secciones: <script setup> para la lógica, <template> para el HTML y <style> para los estilos. La sintaxis <script setup> es el azúcar sintáctico para la Composition API y la forma recomendada hoy en día.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
// Props del componente
const props = defineProps<{
titulo: string
mostrarBoton?: boolean
}>()
// Emits tipados
const emit = defineEmits<{
actualizado: [valor: string]
cerrado: []
}>()
// Estado reactivo con ref
const contador = ref(0)
const nombre = ref('')
// Computed: valor derivado del estado
const mensajeBienvenida = computed(() =>
nombre.value ? `Hola, ${nombre.value}!` : 'Introduce tu nombre'
)
// Método
function incrementar() {
contador.value++
emit('actualizado', String(contador.value))
}
// Ciclo de vida
onMounted(() => {
console.log('Componente montado')
})
</script>
<template>
<div class="componente">
<h2>{{ titulo }}</h2>
<p>{{ mensajeBienvenida }}</p>
<input v-model="nombre" placeholder="Tu nombre" />
<p>Contador: {{ contador }}</p>
<button v-if="mostrarBoton" @click="incrementar">
Incrementar
</button>
</div>
</template>
<style scoped>
.componente {
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>
Reactividad en Vue 3: ref vs reactive
Vue 3 ofrece dos formas principales de declarar estado reactivo: ref() y reactive(). Entender cuándo usar cada uno es fundamental.
ref() se usa para valores primitivos (strings, números, booleanos) y también puede envolver objetos. El valor se accede a través de .value en el script, pero en el template Vue desenvuelve el ref automáticamente.
<script setup lang="ts">
import { ref, reactive, watch, watchEffect } from 'vue'
// ref: para primitivos y cuando necesitas reasignar
const count = ref(0)
const mensaje = ref('hola')
const usuario = ref({ nombre: 'Ana', edad: 28 })
// Acceso con .value en el script
count.value++
usuario.value.nombre = 'Carlos'
// Reasignación completa posible con ref
usuario.value = { nombre: 'María', edad: 32 }
// reactive: para objetos complejos, sin .value
const estado = reactive({
usuarios: [] as Usuario[],
cargando: false,
pagina: 1,
filtro: ''
})
// Modificación directa, sin .value
estado.cargando = true
estado.usuarios.push({ nombre: 'Ana', edad: 28 })
// watch: observar cambios específicos
watch(count, (nuevoValor, valorAnterior) => {
console.log(`Cambió de ${valorAnterior} a ${nuevoValor}`)
})
// watchEffect: ejecuta y re-ejecuta automáticamente
watchEffect(() => {
// Se re-ejecuta cada vez que count o mensaje cambian
console.log(`Count: ${count.value}, Mensaje: ${mensaje.value}`)
})
</script>
La recomendación práctica: usa ref() para la mayoría de los casos. Es más predecible porque siempre tienes que usar .value explícitamente, lo que hace el código más claro. Usa reactive() cuando tengas un objeto de estado complejo con muchas propiedades relacionadas.
Directivas esenciales de Vue
Las directivas de Vue son atributos especiales en el template que le dan comportamiento reactivo al HTML. Son el lenguaje del template de Vue.
<template>
<!-- v-bind: enlazar atributos dinámicamente (shorthand :) -->
<img :src="imagenUrl" :alt="imagenAlt" :class="{ activo: esActivo }" />
<!-- v-on: escuchar eventos (shorthand @) -->
<button @click="manejarClick" @mouseenter="manejarHover">
Clic aquí
</button>
<!-- Modificadores de eventos -->
<form @submit.prevent="enviarFormulario">
<input @keyup.enter="buscar" @input.lazy="actualizar" />
</form>
<!-- v-model: enlace bidireccional -->
<input v-model="texto" />
<input v-model.number="edad" type="number" />
<input v-model.trim="nombre" />
<textarea v-model="descripcion" />
<input type="checkbox" v-model="aceptaTerminos" />
<select v-model="opcionSeleccionada">
<option value="a">Opción A</option>
<option value="b">Opción B</option>
</select>
<!-- v-if / v-else-if / v-else: renderizado condicional -->
<div v-if="estado === 'cargando'">Cargando...</div>
<div v-else-if="estado === 'error'">Ha ocurrido un error</div>
<div v-else>Contenido cargado</div>
<!-- v-show: visibilidad con CSS (el elemento siempre existe en el DOM) -->
<div v-show="mostrarPanel">Panel de control</div>
<!-- v-for: renderizar listas -->
<ul>
<li v-for="item in items" :key="item.id">
{{ item.nombre }} - {{ item.precio }}€
</li>
</ul>
<!-- v-for con índice -->
<div v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.nombre }}
</div>
</template>
Composables: la forma Vue de reutilizar lógica
Los composables son la respuesta de Vue a los custom hooks de React. Son funciones que encapsulan lógica con estado reactivo y se pueden reutilizar entre múltiples componentes. Por convención, sus nombres empiezan con «use».
// composables/useFetch.ts
import { ref, watchEffect } from 'vue'
export function useFetch<T>(url: string | (() => string)) {
const data = ref<T | null>(null)
const error = ref<string | null>(null)
const cargando = ref(true)
watchEffect(async () => {
// Limpiar estado anterior
cargando.value = true
error.value = null
const urlResuelta = typeof url === 'function' ? url() : url
try {
const response = await fetch(urlResuelta)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (e) {
error.value = (e as Error).message
} finally {
cargando.value = false
}
})
return { data, error, cargando }
}
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(clave: string, valorInicial: T) {
const datos = ref<T>(
localStorage.getItem(clave)
? JSON.parse(localStorage.getItem(clave)!)
: valorInicial
)
watch(datos, (nuevoValor) => {
localStorage.setItem(clave, JSON.stringify(nuevoValor))
}, { deep: true })
return datos
}
// composables/useContador.ts
import { ref, computed } from 'vue'
export function useContador(inicial = 0, min = -Infinity, max = Infinity) {
const count = ref(inicial)
const puedeIncrementar = computed(() => count.value < max)
const puedeDecrementar = computed(() => count.value > min)
function incrementar() {
if (puedeIncrementar.value) count.value++
}
function decrementar() {
if (puedeDecrementar.value) count.value--
}
function resetear() {
count.value = inicial
}
return { count, puedeIncrementar, puedeDecrementar, incrementar, decrementar, resetear }
}
// Usar en cualquier componente
// <script setup>
// import { useFetch } from '@/composables/useFetch'
// import { useContador } from '@/composables/useContador'
//
// const { data: usuarios, cargando, error } = useFetch<Usuario[]>('/api/usuarios')
// const { count, incrementar, decrementar } = useContador(0, 0, 10)
// </script>
Pinia: gestión de estado global en Vue 3
Pinia es el gestor de estado oficial de Vue 3 (reemplaza a Vuex). Es más simple, más ligero y tiene soporte nativo para TypeScript. Funciona con el mismo modelo mental que la Composition API.
// stores/useCarritoStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// Sintaxis con Composition API (la más moderna)
export const useCarritoStore = defineStore('carrito', () => {
// Estado
const items = ref<ItemCarrito[]>([])
const descuento = ref(0)
// Getters (computed)
const totalItems = computed(() =>
items.value.reduce((acc, item) => acc + item.cantidad, 0)
)
const subtotal = computed(() =>
items.value.reduce((acc, item) => acc + item.precio * item.cantidad, 0)
)
const total = computed(() =>
subtotal.value * (1 - descuento.value / 100)
)
// Actions
function agregarItem(producto: Producto) {
const itemExistente = items.value.find(i => i.id === producto.id)
if (itemExistente) {
itemExistente.cantidad++
} else {
items.value.push({ ...producto, cantidad: 1 })
}
}
function eliminarItem(id: number) {
items.value = items.value.filter(i => i.id !== id)
}
function vaciarCarrito() {
items.value = []
}
async function procesarPago() {
const response = await fetch('/api/pedidos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: items.value, total: total.value })
})
if (response.ok) {
vaciarCarrito()
return await response.json()
}
throw new Error('Error al procesar el pago')
}
return {
items, descuento,
totalItems, subtotal, total,
agregarItem, eliminarItem, vaciarCarrito, procesarPago
}
})
// Usar en cualquier componente
// <script setup>
// import { useCarritoStore } from '@/stores/useCarritoStore'
// const carrito = useCarritoStore()
// </script>
//
// <template>
// <p>{{ carrito.totalItems }} productos - {{ carrito.total }}€</p>
// <button @click="carrito.agregarItem(producto)">Añadir</button>
// </template>
Vue Router: navegación en Single Page Applications
Vue Router es el router oficial de Vue. Permite crear aplicaciones con múltiples páginas sin recargas del navegador, con lazy loading de componentes, rutas con parámetros, guards de navegación y más.
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'Inicio',
component: () => import('@/views/InicioView.vue')
},
{
path: '/productos',
name: 'Productos',
component: () => import('@/views/ProductosView.vue')
},
{
path: '/producto/:id',
name: 'DetalleProducto',
component: () => import('@/views/DetalleProductoView.vue'),
props: true // Pasa los params como props al componente
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiereAuth: true },
children: [
{
path: 'dashboard',
component: () => import('@/views/admin/DashboardView.vue')
},
{
path: 'usuarios',
component: () => import('@/views/admin/UsuariosView.vue')
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue')
}
]
})
// Navigation Guard: proteger rutas
router.beforeEach((to, from) => {
const estaAutenticado = !!localStorage.getItem('token')
if (to.meta.requiereAuth && !estaAutenticado) {
return { name: 'Login' }
}
})
export default router
// Usar el router en componentes
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Acceder a parámetros de la URL
const productoId = route.params.id
const busqueda = route.query.q
// Navegar programáticamente
function irAProducto(id: number) {
router.push({ name: 'DetalleProducto', params: { id } })
}
function volver() {
router.back()
}
</script>
<template>
<!-- RouterLink para navegación declarativa -->
<RouterLink to="/">Inicio</RouterLink>
<RouterLink :to="{ name: 'Productos' }">Productos</RouterLink>
<RouterLink :to="{ name: 'DetalleProducto', params: { id: 42 } }">
Ver producto
</RouterLink>
<!-- RouterView: donde se renderizan las rutas -->
<RouterView />
</template>
Ciclo de vida de los componentes
Vue tiene un ciclo de vida bien definido que te permite ejecutar código en momentos específicos: cuando el componente se monta, cuando se actualiza y cuando se desmonta. Con la Composition API, se usan hooks de ciclo de vida:
<script setup lang="ts">
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
// Antes de que el componente se monte (DOM no disponible aún)
onBeforeMount(() => {
console.log('A punto de montarse')
})
// Después de montarse (DOM disponible)
onMounted(() => {
console.log('Componente montado')
// Aquí puedes acceder al DOM, inicializar librerías, hacer fetch, etc.
cargarDatos()
})
// Antes de una actualización reactiva
onBeforeUpdate(() => {
console.log('A punto de actualizarse')
})
// Después de actualizarse
onUpdated(() => {
console.log('Componente actualizado')
})
// Cleanup: cuando el componente se va a desmontar
onBeforeUnmount(() => {
// Limpiar suscripciones, timers, event listeners, etc.
clearInterval(miTimer)
document.removeEventListener('keydown', manejarTeclado)
})
onUnmounted(() => {
console.log('Componente desmontado')
})
</script>
Componentes asíncronos y Suspense
Vue 3 tiene soporte nativo para componentes asíncronos y el componente especial <Suspense> para manejar estados de carga de forma declarativa:
// Definir componente asíncrono
import { defineAsyncComponent } from 'vue'
const GraficoHeavy = defineAsyncComponent({
loader: () => import('./components/GraficoHeavy.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorMessage,
delay: 200, // Esperar 200ms antes de mostrar el loading
timeout: 5000 // Error si tarda más de 5 segundos
})
// Usar Suspense en el template
// <Suspense>
// <template #default>
// <ComponenteAsincrono />
// </template>
// <template #fallback>
// <p>Cargando componente...</p>
// </template>
// </Suspense>
Slots: composición de componentes avanzada
Los slots son el mecanismo de Vue para que los componentes acepten contenido HTML arbitrario de su componente padre. Son esenciales para crear componentes reutilizables y genéricos como cards, modales, layouts y tablas.
// Componente Modal con slots
// <!-- Modal.vue -->
// <template>
// <Teleport to="body">
// <div v-if="abierto" class="modal-overlay" @click.self="cerrar">
// <div class="modal">
// <header class="modal-header">
// <slot name="titulo">Modal</slot> <!-- Slot con fallback -->
// <button @click="cerrar">✕</button>
// </header>
// <div class="modal-body">
// <slot /> <!-- Slot por defecto -->
// </div>
// <footer class="modal-footer">
// <slot name="acciones">
// <button @click="cerrar">Cerrar</button>
// </slot>
// </footer>
// </div>
// </div>
// </Teleport>
// </template>
// Usar el modal
// <Modal :abierto="mostrarModal" @cerrar="mostrarModal = false">
// <template #titulo>Confirmar acción</template>
//
// <p>¿Estás seguro de que quieres eliminar este elemento?</p>
//
// <template #acciones>
// <button @click="cancelar">Cancelar</button>
// <button @click="confirmar" class="btn-danger">Eliminar</button>
// </template>
// </Modal>
Comunicación entre componentes: props, emits y provide/inject
Vue tiene varios mecanismos para que los componentes se comuniquen entre sí. Los props son para comunicación de padre a hijo (datos hacia abajo). Los emits son para comunicación de hijo a padre (eventos hacia arriba). provide/inject es para compartir datos en árboles de componentes profundos sin prop drilling.
// Componente padre: provide
// <script setup>
// import { provide, ref } from 'vue'
//
// const tema = ref('claro')
// provide('tema', tema)
// provide('cambiarTema', (nuevoTema: string) => { tema.value = nuevoTema })
// </script>
// Componente nieto: inject (cualquier nivel de profundidad)
<script setup lang="ts">
import { inject, ref, type Ref } from 'vue'
// Con valor por defecto
const tema = inject<Ref<string>>('tema', ref('claro'))
const cambiarTema = inject<(t: string) => void>('cambiarTema', () => {})
</script>
Transiciones y animaciones
Vue incluye un sistema de transiciones muy potente integrado en el framework. El componente <Transition> aplica clases CSS automáticamente cuando un elemento aparece o desaparece.
<template>
<!-- Transición CSS simple -->
<Transition name="fade">
<p v-if="mostrar">Este elemento tiene transición</p>
</Transition>
<!-- Lista con transiciones -->
<TransitionGroup name="lista" tag="ul">
<li v-for="item in items" :key="item.id">{{ item.nombre }}</li>
</TransitionGroup>
</template>
<style>
/* Clases generadas por Transition: fade-enter-active, fade-leave-active, etc. */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Animación de lista */
.lista-enter-active,
.lista-leave-active {
transition: all 0.3s ease;
}
.lista-enter-from,
.lista-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.lista-move {
transition: transform 0.3s ease;
}
</style>
Vue 3 vs React vs Angular: ¿cuándo elegir Vue?
Esta es la pregunta del millón. Vue 3 brilla especialmente en varios escenarios. Primero, cuando tienes un equipo con desarrolladores de diferentes niveles: Vue tiene una curva de aprendizaje más suave que Angular y su template HTML es más intuitivo que JSX para quienes vienen de HTML puro. Segundo, cuando necesitas productividad rápida: la Composition API con <script setup> es extremadamente concisa. Tercero, cuando construyes aplicaciones medianas: Vue escala bien pero sin la complejidad de configuración de Angular en proyectos grandes.
React sigue siendo el rey del mercado laboral, pero Vue tiene una comunidad enorme, especialmente fuera del mercado anglosajón. Angular sigue dominando en entornos enterprise con equipos grandes. Vue es el término medio: amigable, potente y productivo.
Próximos pasos con Vue 3
Con lo que has visto en esta guía ya tienes las bases sólidas para construir aplicaciones Vue 3 reales. Los próximos temas que deberías explorar son: el uso de defineModel() para crear inputs de formulario personalizados más limpios, Nuxt 3 para SSR y SSG con Vue, la integración de Vue con librerías de componentes UI como PrimeVue o Vuetify, testing de componentes con Vitest y Vue Test Utils, y las DevTools de Vue para debuggear la reactividad en el navegador.