Skip to content

Vue.js: De 0 a Profesional

Tutorial

30 min read

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+)
  • Ecosistema maduro: Pinia, Vue Router, Vite, Nuxt 3

¿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)

npm create vue@latest mi-proyecto

Selecciona las opciones recomendadas:

  • ✅ TypeScript
  • ✅ Vue Router
  • ✅ Pinia
  • ✅ Vitest
  • ✅ ESLint + Prettier
cd mi-proyecto
npm install
npm run dev

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

<!-- ❌ Options API (evitar en proyectos nuevos) -->
<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() { this.count++ }
  }
}
</script>

<!-- ✅ Composition API con <script setup> -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
const increment = () => count.value++
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

Props y Emits tipados

<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 defecto
const { title, count = 0 } = defineProps<{
  title: string
  count?: number
}>()

// Emits tipados
const emit = defineEmits<{
  update: [value: number]
  close: []
}>()

// Uso
emit('update', 42)
</script>

Ciclo de vida

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

4. Reactividad en profundidad

ref vs reactive

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

computed — valores derivados

const firstName = ref('Ana')
const lastName = ref('García')

// ✅ computed es lazy y cacheado
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

// computed con getter y setter
const reversedName = computed({
  get: () => fullName.value.split('').reverse().join(''),
  set: (val: string) => {
    firstName.value = val.split(' ')[0]
  }
})

watch y watchEffect

import { watch, watchEffect, ref } from 'vue'

const query = ref('')
const results = ref([])

// watch: control explícito sobre qué observar
watch(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áticamente
watchEffect(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 3.5+: Reactive Props Destructure

<script setup lang="ts">
// A partir de Vue 3.5, las props son reactivas al desestructurar
const { count, title = 'Default' } = defineProps<{
  count: number
  title?: string
}>()

// count y title son reactivos sin necesidad de toRefs
</script>

5. Componentes: arquitectura y comunicación

Componente bien estructurado

<!-- components/ui/AppButton.vue -->
<script setup lang="ts">
type Variant = 'primary' | 'secondary' | 'danger'
type Size = 'sm' | 'md' | 'lg'

const props = withDefaults(defineProps<{
  variant?: Variant
  size?: Size
  loading?: boolean
  disabled?: boolean
}>(), {
  variant: 'primary',
  size: 'md',
  loading: false,
  disabled: false,
})

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()
</script>

<template>
  <button
    :class="[
      'btn',
      `btn--${variant}`,
      `btn--${size}`,
      { 'btn--loading': loading }
    ]"
    :disabled="disabled || loading"
    @click="emit('click', $event)"
  >
    <span v-if="loading" class="spinner" aria-hidden="true" />
    <slot />
  </button>
</template>

Comunicación entre componentes

<!-- Padre → Hijo: Props -->
<ChildComponent :user="currentUser" :items="list" />

<!-- Hijo → Padre: Emits -->
<ChildComponent @update:user="handleUpdate" />

<!-- Bidireccional con v-model (Vue 3) -->
<!-- Componente padre -->
<MyInput v-model="username" />
<!-- equivale a -->
<MyInput :modelValue="username" @update:modelValue="username = $event" />

<!-- Componente MyInput.vue -->
<script setup lang="ts">
defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
  <input :value="modelValue" @input="emit('update:modelValue', $event.target.value)" />
</template>

Provide / Inject (para evitar prop drilling)

// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'

export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')

// Componente raíz o layout
import { provide, ref } from 'vue'
import { ThemeKey } from '@/types/injection-keys'

const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme)

// Componente nieto
import { inject } from 'vue'
import { ThemeKey } from '@/types/injection-keys'

const theme = inject(ThemeKey) // type-safe gracias a InjectionKey

Slots avanzados

<!-- components/DataTable.vue -->
<template>
  <table>
    <thead>
      <slot name="header" :columns="columns" />
    </thead>
    <tbody>
      <tr v-for="row in rows" :key="row.id">
        <slot name="row" :row="row" :index="index" />
      </tr>
    </tbody>
    <tfoot>
      <slot name="footer" />
    </tfoot>
  </table>
</template>

<!-- Uso con scoped slots -->
<DataTable :rows="users">
  <template #header="{ columns }">
    <th v-for="col in columns">{{ col.label }}</th>
  </template>
  <template #row="{ row, index }">
    <td>{{ index + 1 }}</td>
    <td>{{ row.name }}</td>
  </template>
</DataTable>

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

// composables/useFetch.ts
import { ref, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'

interface UseFetchOptions {
  immediate?: boolean
}

export function useFetch<T>(
  url: MaybeRefOrGetter<string>,
  options: UseFetchOptions = { immediate: true }
) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  async function execute() {
    isLoading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url))
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json() as T
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      isLoading.value = false
    }
  }

  if (options.immediate) {
    watchEffect(() => {
      toValue(url) // hace que watchEffect track cambios en url
      execute()
    })
  }

  return { data, error, isLoading, execute }
}

// Uso en componente
const { data: users, isLoading } = useFetch<User[]>('/api/users')
const endpoint = computed(() => `/api/users/${userId.value}`)
const { data: user } = useFetch<User>(endpoint) // reactivo a cambios

Composable con estado local: useCounter

// composables/useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initial = 0, { min = -Infinity, max = Infinity } = {}) {
  const count = ref(initial)

  const increment = (step = 1) => {
    count.value = Math.min(count.value + step, max)
  }

  const decrement = (step = 1) => {
    count.value = Math.max(count.value - step, min)
  }

  const reset = () => { count.value = initial }

  const isAtMax = computed(() => count.value >= max)
  const isAtMin = computed(() => count.value <= min)

  return { count, increment, decrement, reset, isAtMax, isAtMin }
}

Composable con efectos secundarios: useEventListener

// composables/useEventListener.ts
import { onMounted, onUnmounted } from 'vue'

export function useEventListener<K extends keyof WindowEventMap>(
  target: EventTarget | Window,
  event: K,
  handler: (event: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions
) {
  onMounted(() => target.addEventListener(event, handler as EventListener, options))
  onUnmounted(() => target.removeEventListener(event, handler as EventListener, options))
}

// Uso
useEventListener(window, 'resize', () => {
  console.log('Window resized:', window.innerWidth)
})

Composable de formularios: useForm

// composables/useForm.ts
import { reactive, ref } from 'vue'

type Validator<T> = (value: T) => string | null

interface FieldConfig<T> {
  initial: T
  validators?: Validator<T>[]
}

export function useForm<T extends Record<string, unknown>>(
  config: { [K in keyof T]: FieldConfig<T[K]> }
) {
  const values = reactive({} as T)
  const errors = reactive({} as Record<keyof T, string | null>)
  const isSubmitting = ref(false)

  // Inicializar valores
  for (const key in config) {
    values[key] = config[key].initial as T[typeof key]
    errors[key] = null
  }

  function validate(): boolean {
    let valid = true
    for (const key in config) {
      const validators = config[key].validators ?? []
      for (const validator of validators) {
        const error = validator(values[key] as T[typeof key])
        if (error) {
          errors[key] = error
          valid = false
          break
        }
        errors[key] = null
      }
    }
    return valid
  }

  async function handleSubmit(onSubmit: (values: T) => Promise<void>) {
    if (!validate()) return
    isSubmitting.value = true
    try {
      await onSubmit(values as T)
    } finally {
      isSubmitting.value = false
    }
  }

  return { values, errors, isSubmitting, validate, handleSubmit }
}

// Uso
const { values, errors, handleSubmit } = useForm({
  email: {
    initial: '',
    validators: [
      (v) => !v ? 'Requerido' : null,
      (v) => !v.includes('@') ? 'Email inválido' : null,
    ]
  },
  password: {
    initial: '',
    validators: [(v) => v.length < 8 ? 'Mínimo 8 caracteres' : null]
  }
})

7. Vue Router moderno

Configuración con layouts y guards

// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: () => import('@/layouts/AppLayout.vue'),
    children: [
      {
        path: '',
        name: 'home',
        component: () => import('@/pages/HomePage.vue'),
      },
      {
        path: 'dashboard',
        name: 'dashboard',
        component: () => import('@/pages/DashboardPage.vue'),
        meta: { requiresAuth: true },
      },
    ],
  },
  {
    path: '/auth',
    component: () => import('@/layouts/AuthLayout.vue'),
    children: [
      {
        path: 'login',
        name: 'login',
        component: () => import('@/pages/LoginPage.vue'),
        meta: { requiresGuest: true },
      },
    ],
  },
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/pages/NotFoundPage.vue'),
  },
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition
    if (to.hash) return { el: to.hash, behavior: 'smooth' }
    return { top: 0 }
  },
})

// Navigation Guard global
router.beforeEach(async (to) => {
  const auth = useAuthStore()

  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }

  if (to.meta.requiresGuest && auth.isLoggedIn) {
    return { name: 'dashboard' }
  }
})

export default router

Composable de router

<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

// Navegar programáticamente
const goToDashboard = () => router.push({ name: 'dashboard' })

// Leer parámetros
const userId = computed(() => route.params.id as string)
const page = computed(() => Number(route.query.page ?? 1))
</script>

8. Pinia: estado global

Pinia es el reemplazo oficial de Vuex. Es simple, type-safe y compatible con Vue DevTools.

Store básico

// stores/users.ts
import { 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,
  }
})

Uso en componentes

<script setup lang="ts">
import { useUsersStore } from '@/stores/users'
import { storeToRefs } from 'pinia'

const store = useUsersStore()

// ✅ storeToRefs preserva reactividad al desestructurar
const { users, isLoading, currentUser } = storeToRefs(store)

// Las actions se pueden desestructurar directamente (no son reactivas)
const { fetchUsers, updateUser } = store

onMounted(() => fetchUsers())
</script>

9. Rendimiento y optimización

Lazy loading de componentes

// ✅ Carga diferida: el componente solo se descarga cuando se necesita
const HeavyChart = defineAsyncComponent(() =>
  import('@/components/HeavyChart.vue')
)

// Con loading y error states
const 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
})

v-memo y v-once

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

shallowRef y shallowReactive

import { shallowRef, shallowReactive, triggerRef } from 'vue'

// Para grandes arrays/objetos donde no necesitas reactividad profunda
const bigList = shallowRef<Item[]>([])

// Para mutar y notificar manualmente
bigList.value.push(newItem)
triggerRef(bigList) // fuerza actualización

KeepAlive para preservar estado de componentes

<template>
  <KeepAlive :include="['SearchResults', 'UserProfile']" :max="5">
    <component :is="currentView" />
  </KeepAlive>
</template>

10. Testing

Unit testing con Vitest + Vue Test Utils

// components/__tests__/AppButton.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AppButton from '../AppButton.vue'

describe('AppButton', () => {
  it('renderiza el slot correctamente', () => {
    const wrapper = mount(AppButton, {
      slots: { default: 'Guardar' }
    })
    expect(wrapper.text()).toBe('Guardar')
  })

  it('emite click event', async () => {
    const wrapper = mount(AppButton)
    await wrapper.trigger('click')
    expect(wrapper.emitted('click')).toHaveLength(1)
  })

  it('está deshabilitado cuando loading es true', () => {
    const wrapper = mount(AppButton, { props: { loading: true } })
    expect(wrapper.attributes('disabled')).toBeDefined()
  })
})

Testing de composables

// composables/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('inicializa con valor por defecto', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })

  it('respeta el límite máximo', () => {
    const { count, increment } = useCounter(0, { max: 3 })
    increment(10)
    expect(count.value).toBe(3)
  })
})

Testing de stores de Pinia

// stores/__tests__/users.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useUsersStore } from '../users'
import * as UserService from '@/services/UserService'

describe('useUsersStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.clearAllMocks()
  })

  it('fetchUsers carga usuarios correctamente', async () => {
    const mockUsers = [{ id: '1', name: 'Ana', active: true }]
    vi.spyOn(UserService, 'getAll').mockResolvedValue(mockUsers)

    const store = useUsersStore()
    await store.fetchUsers()

    expect(store.users).toEqual(mockUsers)
    expect(store.isLoading).toBe(false)
  })
})

11. Patrones avanzados

Patrón Renderless Components

<!-- components/RenderlessDataFetcher.vue -->
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'

const props = defineProps<{ url: string }>()
const { data, error, isLoading } = useFetch(computed(() => props.url))
</script>

<template>
  <slot :data="data" :error="error" :isLoading="isLoading" />
</template>

<!-- Uso: total control sobre el template -->
<RenderlessDataFetcher url="/api/users">
  <template #default="{ data, isLoading, error }">
    <Spinner v-if="isLoading" />
    <Alert v-else-if="error" :message="error.message" />
    <UserList v-else :users="data" />
  </template>
</RenderlessDataFetcher>

Plugin de Vue

// plugins/toast.ts
import 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.ts
app.use(toastPlugin)

// Uso en composable
export function useToast() {
  return inject<ToastPlugin>('toast')!
}

Teleport: renderizar fuera del DOM del componente

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

12. Buenas prácticas y arquitectura de proyecto

Reglas de oro

1. Un componente, una responsabilidad

❌ UserDashboard.vue (hace demasiado)
✅ UserProfile.vue + UserStats.vue + UserActivity.vue

2. Tipado estricto siempre

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

3. Abstrae las llamadas a API en services

// services/UserService.ts
const BASE = '/api/users'

export const UserService = {
  getAll: (): Promise<User[]> =>
    fetch(BASE).then(r => r.json()),

  getById: (id: string): Promise<User> =>
    fetch(`${BASE}/${id}`).then(r => r.json()),

  update: (id: string, data: Partial<User>): Promise<User> =>
    fetch(`${BASE}/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    }).then(r => r.json()),
}

4. Nombres semánticos para composables y stores

✅ useCurrentUser, useProductSearch, useCartActions
❌ useData, useHelper, useMisc

5. Exports centralizados con index.ts

// components/ui/index.ts
export { 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'

6. Variables de entorno tipadas

// env.d.ts
interface ImportMetaEnv {
  readonly VITE_API_URL: string
  readonly VITE_APP_TITLE: string
}
interface ImportMeta {
  readonly env: ImportMetaEnv
}

7. Usa defineOptions para nombrar componentes

<script setup lang="ts">
defineOptions({ name: 'UserProfileCard' })
</script>

Checklist de Code Review Vue

  • ¿Los componentes tienen menos de 200 líneas?
  • ¿Toda la lógica reutilizable está en composables?
  • ¿Las props y emits están correctamente tipados?
  • ¿Se usa storeToRefs al desestructurar stores?
  • ¿Las rutas usan lazy loading (() => import(...))
  • ¿Los efectos secundarios se limpian en onUnmounted?
  • ¿Hay tests unitarios para composables y stores?
  • ¿Se evita acceder directamente al DOM (usar ref en su lugar)?

Recursos imprescindibles