Tutorial completo con mejores prácticas, modularidad y patrones modernos (2025)
1. ¿Qué es Vue y por qué usarlo?
Vue 3 es un framework progresivo para construir interfaces de usuario. A diferencia de React (que es una librería) o Angular (framework completo), Vue ocupa un punto intermedio: puedes adoptarlo incrementalmente.
Ventajas clave en 2025:
Composition API: lógica reutilizable y tipado limpio
<script setup>: menos boilerplate que cualquier otro framework
Reactividad granular y eficiente (sin Virtual DOM diffing agresivo desde Vue 3.4+)
¿Viene de React? La Composition API es conceptualmente similar a los hooks. Los composables son los custom hooks de Vue. Pinia equivale a Zustand. ref() ≈ useState().
2. Instalación y entorno de trabajo
Crear un proyecto nuevo (2025)
Selecciona las opciones recomendadas:
✅ TypeScript
✅ Vue Router
✅ Pinia
✅ Vitest
✅ ESLint + Prettier
Estructura de carpetas profesional
src/
├── assets/ # Imágenes, fuentes, estilos globales
├── components/ # Componentes reutilizables (atómicos)
│ ├── ui/ # Botones, inputs, modales (sin lógica de negocio)
│ └── shared/ # Componentes con algo de lógica compartida
├── composables/ # Lógica reutilizable (custom hooks)
├── layouts/ # Layouts de página (AppLayout, AuthLayout...)
├── pages/ # Vistas/páginas (mapeadas con Vue Router)
├── router/ # Configuración del router
├── stores/ # Stores de Pinia
├── services/ # Llamadas a API (fetch/axios abstraído)
├── types/ # Interfaces y tipos TypeScript
└── utils/ # Funciones puras de utilidad
3. Fundamentos: Composition API
<script setup> — La forma moderna
Props y Emits tipados
Ciclo de vida
4. Reactividad en profundidad
ref vs reactive
computed — valores derivados
watch y watchEffect
Vue 3.5+: Reactive Props Destructure
5. Componentes: arquitectura y comunicación
Componente bien estructurado
Comunicación entre componentes
Provide / Inject (para evitar prop drilling)
Slots avanzados
6. Composables: lógica reutilizable
Los composables son la pieza más importante de la arquitectura Vue moderna. Equivalen a los custom hooks de React.
Convenciones
Nombre con prefijo use: useFetch, useLocalStorage, useDebounce
Retornan refs reactivos
Se llaman en el setup (top-level)
Pueden recibir argumentos reactivos (MaybeRefOrGetter)
Composable básico: useFetch
Composable con estado local: useCounter
Composable con efectos secundarios: useEventListener
Composable de formularios: useForm
7. Vue Router moderno
Configuración con layouts y guards
Composable de router
8. Pinia: estado global
Pinia es el reemplazo oficial de Vuex. Es simple, type-safe y compatible con Vue DevTools.
<script setup lang="ts">// Props con TypeScript (sin defineProps genérico en Vue 3.3+)const props = defineProps<{ title: string count?: number items: string[]}>()// Con valores por defectoconst { title, count = 0 } = defineProps<{ title: string count?: number}>()// Emits tipadosconst emit = defineEmits<{ update: [value: number] close: []}>()// Usoemit('update', 42)</script>
vue
<script setup lang="ts">import { onMounted, onUnmounted, onBeforeMount } from 'vue'onMounted(() => { console.log('Componente montado en el DOM')})onUnmounted(() => { console.log('Limpieza de efectos secundarios aquí')})</script>
typescript
import { ref, reactive, computed, watch, watchEffect } from 'vue'// ref: para primitivos y cualquier valor (recomendado por consistencia)const name = ref('Ana')console.log(name.value) // acceso mediante .value en JS/TS// reactive: para objetos (pierde reactividad si se desestructura)const state = reactive({ x: 0, y: 0 })console.log(state.x) // no necesita .value// ✅ Regla de oro: usa ref por defecto// Usa reactive solo cuando tengas un grupo de valores fuertemente relacionados
import { watch, watchEffect, ref } from 'vue'const query = ref('')const results = ref([])// watch: control explícito sobre qué observarwatch(query, async (newVal, oldVal) => { if (newVal.length > 2) { results.value = await fetchResults(newVal) }}, { debounce: 300 // Vue 3.5+: debounce nativo})// watchEffect: ejecuta inmediatamente y rastrea dependencias automáticamentewatchEffect(async () => { // Todo lo que se lea aquí es tracked automáticamente if (query.value.length > 2) { results.value = await fetchResults(query.value) }})// Limpiar efectos secundarios (WebSockets, timers...)watchEffect((onCleanup) => { const timer = setInterval(() => poll(), 1000) onCleanup(() => clearInterval(timer))})
vue
<script setup lang="ts">// A partir de Vue 3.5, las props son reactivas al desestructurarconst { count, title = 'Default' } = defineProps<{ count: number title?: string}>()// count y title son reactivos sin necesidad de toRefs</script>
// stores/users.tsimport { defineStore } from 'pinia'import { ref, computed } from 'vue'import type { User } from '@/types'import { UserService } from '@/services/UserService'// Composition Store (recomendado sobre Options Store)export const useUsersStore = defineStore('users', () => { // State const users = ref<User[]>([]) const currentUserId = ref<string | null>(null) const isLoading = ref(false) // Getters const currentUser = computed(() => users.value.find(u => u.id === currentUserId.value) ?? null ) const activeUsers = computed(() => users.value.filter(u => u.active) ) // Actions async function fetchUsers() { isLoading.value = true try { users.value = await UserService.getAll() } finally { isLoading.value = false } } async function updateUser(id: string, payload: Partial<User>) { const updated = await UserService.update(id, payload) const index = users.value.findIndex(u => u.id === id) if (index !== -1) users.value[index] = updated } function setCurrentUser(id: string) { currentUserId.value = id } return { // State users, currentUserId, isLoading, // Getters currentUser, activeUsers, // Actions fetchUsers, updateUser, setCurrentUser, }})
vue
<script setup lang="ts">import { useUsersStore } from '@/stores/users'import { storeToRefs } from 'pinia'const store = useUsersStore()// ✅ storeToRefs preserva reactividad al desestructurarconst { users, isLoading, currentUser } = storeToRefs(store)// Las actions se pueden desestructurar directamente (no son reactivas)const { fetchUsers, updateUser } = storeonMounted(() => fetchUsers())</script>
typescript
// ✅ Carga diferida: el componente solo se descarga cuando se necesitaconst HeavyChart = defineAsyncComponent(() => import('@/components/HeavyChart.vue'))// Con loading y error statesconst HeavyChart = defineAsyncComponent({ loader: () => import('@/components/HeavyChart.vue'), loadingComponent: LoadingSpinner, errorComponent: ErrorFallback, delay: 200, // ms antes de mostrar loading timeout: 10000, // ms antes de mostrar error})
vue
<template> <!-- v-once: renderiza una sola vez, nunca actualiza --> <AppHeader v-once /> <!-- v-memo: solo actualiza si cambian los valores de la lista --> <div v-for="item in list" :key="item.id" v-memo="[item.selected, item.name]"> <ExpensiveItem :item="item" /> </div></template>
typescript
import { shallowRef, shallowReactive, triggerRef } from 'vue'// Para grandes arrays/objetos donde no necesitas reactividad profundaconst bigList = shallowRef<Item[]>([])// Para mutar y notificar manualmentebigList.value.push(newItem)triggerRef(bigList) // fuerza actualización
// plugins/toast.tsimport type { App } from 'vue'import ToastContainer from '@/components/ToastContainer.vue'interface ToastPlugin { success(message: string): void error(message: string): void}export const toastPlugin = { install(app: App) { const toast: ToastPlugin = { success(message) { /* ... */ }, error(message) { /* ... */ }, } app.provide('toast', toast) app.config.globalProperties.$toast = toast }}// main.tsapp.use(toastPlugin)// Uso en composableexport function useToast() { return inject<ToastPlugin>('toast')!}
vue
<template> <button @click="showModal = true">Abrir modal</button> <!-- Se renderiza directamente en <body>, no donde está el componente --> <Teleport to="body"> <AppModal v-if="showModal" @close="showModal = false"> Contenido del modal </AppModal> </Teleport></template>
// components/ui/index.tsexport { default as AppButton } from './AppButton.vue'export { default as AppInput } from './AppInput.vue'export { default as AppModal } from './AppModal.vue'// En cualquier lugar del proyecto:import { AppButton, AppInput } from '@/components/ui'