Skip to content

🎯 Meta: dominar Vue 3 con la Composition API: reactividad, componentes SFC, formularios, rutas con Vue Router, estado global con Pinia y tests. Verás que el 70% de los conceptos ya los trajiste de React (cap. 17) — cambia la sintaxis, no las ideas.

Versiones: Vue 3.5 (estable) · Vue Router 4 · Pinia 3 · Vite 8 · Vitest 3 + Vue Test Utils / Testing Library.

⚠️ Vue 3.6 está en release candidate (jul-2026): reescribe la reactividad con alien-signals (más rápida y con menos memoria) y trae Vapor Mode estable — un modo de compilación sin Virtual DOM para componentes ultra-rápidos. Es opt-in y compatible: lo que aprendas aquí vale igual. No uses la RC en producción; espera la estable.


19.1 · Qué es Vue y en qué se diferencia de React

Vue es un framework progresivo: puedes usarlo como un script en una página existente (como Alpine, cap. 16.8) o como SPA completa con router y stores. Su seña de identidad: plantillas HTML + reactividad automática, en vez del "todo es JavaScript" de React.

React (cap. 17)Vue
UIJSX (JS que genera HTML)Plantillas HTML con directivas
ReactividadRe-ejecuta el componente al cambiar estadoRastrea dependencias: actualiza solo lo afectado
Estado localuseState + inmutabilidad estrictaref() — puedes mutar tranquilamente
MemoizaciónReact CompilerNo hace falta: computed cachea solo
CurvaMedia (efectos, reglas de hooks)Suave: HTML mejorado

🧠 La gran diferencia mental: en React el componente entero se re-ejecuta y tú evitas mutaciones; en Vue la función setup corre una vez y la reactividad hace el resto. Por eso en Vue mutar (lista.value.push(x)) es correcto y normal.


19.2 · Proyecto nuevo y anatomía de un SFC

bash
npm create vue@latest tienda-vue
# ✔ TypeScript ✔ Vue Router ✔ Pinia ✔ Vitest ✔ ESLint
cd tienda-vue && npm install && npm run dev    # http://localhost:5173

Todo componente es un SFC (Single File Component), un .vue con tres bloques:

vue
<!-- src/components/TarjetaProducto.vue -->
<script setup lang="ts">
// setup: corre UNA vez al crear el componente. Todo lo declarado se usa en template.
interface Props {
  nombre: string;
  precio: number;
  agotado?: boolean;
}
const props = withDefaults(defineProps<Props>(), { agotado: false });

// Eventos tipados que este componente emite hacia el padre
const emit = defineEmits<{ comprar: [nombre: string] }>();
</script>

<template>
  <article class="card">
    <h3>{{ nombre }}</h3>                     <!-- interpolación -->
    <p>{{ precio.toFixed(2) }} €</p>
    <span v-if="agotado" class="badge">Agotado</span>
    <button :disabled="agotado" @click="emit('comprar', nombre)">
      Comprar
    </button>
  </article>
</template>

<style scoped>
/* scoped: estos estilos SOLO afectan a este componente */
.card { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; }
</style>

Uso desde el padre — datos bajan con props, eventos suben con @:

vue
<TarjetaProducto
  v-for="p in productos"
  :key="p.id"
  :nombre="p.nombre"
  :precio="p.precio"
  @comprar="añadirAlCarrito"
/>

📘 Verás por internet la Options API (data(), methods, computed: {}). Es el estilo antiguo de Vue 2. Todo lo moderno usa <script setup> + Composition API — este capítulo también. Si un tutorial usa this., es del estilo viejo.


19.3 · Las directivas del template

DirectivaAtajoQué hace
v-bind:src="x":src="x"Atributo dinámico
v-on:click="f"@click="f"Escuchar evento
v-if / v-else-if / v-elseRenderizado condicional (monta/desmonta)
v-showCondicional con CSS display (para toggles frecuentes)
v-for="p in lista"Bucles (siempre con :key)
v-modelBinding bidireccional en formularios
v-html⚠️ HTML crudo — riesgo XSS, solo contenido propio
vue
<template>
  <!-- clases y estilos dinámicos -->
  <div :class="{ activo: seleccionado, 'con-error': hayError }">…</div>

  <!-- eventos con modificadores: adiós preventDefault manual -->
  <form @submit.prevent="enviar">…</form>
  <input @keyup.enter="buscar">
  <button @click.once="init">…</button>
</template>

19.4 · Reactividad: ref, computed, watch

ref — el useState de Vue

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

const cuenta = ref(0);                       // Ref<number>
const productos = ref<Producto[]>([]);

function incrementar() {
  cuenta.value++;                            // en <script>: SIEMPRE .value
  productos.value.push(nuevo);               // mutar está BIEN en Vue ✅
}
</script>

<template>
  <!-- en el template NO se pone .value: se desenvuelve solo -->
  <button @click="incrementar">Clicks: {{ cuenta }}</button>
</template>

⚠️ El error nº 1 en Vue: olvidar .value en el script (o ponerlo en el template). Regla: script → .value; template → sin él. También existe reactive() para objetos (sin .value), pero pierde reactividad al desestructurar: usa ref por defecto y evitas sustos.

computed — valores derivados con caché automática

typescript
import { computed } from 'vue';

const carrito = ref<Item[]>([]);
const total = computed(() =>
  carrito.value.reduce((suma, i) => suma + i.precio * i.cantidad, 0)
);
// total.value se recalcula SOLO cuando carrito cambia. Nunca lo guardes en un ref
// aparte "sincronizado a mano": derivar > duplicar (misma regla que en React).

watch / watchEffect — reaccionar a cambios (el "useEffect" de Vue)

typescript
import { watch, watchEffect } from 'vue';

// watch: fuente explícita + valor anterior; ideal para "cuando X cambie, haz Y"
watch(busqueda, async (nueva, anterior) => {
  resultados.value = await api.buscar(nueva);
});

// watchEffect: rastrea solo lo que usas dentro; corre ya al inicio
watchEffect(() => {
  localStorage.setItem('carrito', JSON.stringify(carrito.value));
});

Ciclo de vida

typescript
import { onMounted, onUnmounted } from 'vue';

onMounted(() => {            // como useEffect(..., []) — el DOM ya existe
  const id = setInterval(tick, 1000);
  onUnmounted(() => clearInterval(id));      // cleanup
});

19.5 · Formularios con v-model

v-model = valor + evento de actualización en uno (lo que en React eran value + onChange):

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

const nombre = ref('');
const precio = ref<number | null>(null);
const categoria = ref('perifericos');
const acepta = ref(false);

const valido = computed(() => nombre.value.trim().length >= 2 && (precio.value ?? 0) > 0);

const emit = defineEmits<{ crear: [{ nombre: string; precio: number }] }>();

function enviar() {
  emit('crear', { nombre: nombre.value.trim(), precio: precio.value! });
  nombre.value = ''; precio.value = null;
}
</script>

<template>
  <form @submit.prevent="enviar">
    <input v-model.trim="nombre" placeholder="Nombre" required>
    <input v-model.number="precio" type="number" step="0.01" required>
    <select v-model="categoria">
      <option value="perifericos">Periféricos</option>
      <option value="audio">Audio</option>
    </select>
    <label><input v-model="acepta" type="checkbox"> Acepto condiciones</label>
    <button :disabled="!valido">Crear</button>
  </form>
</template>

Modificadores: .trim (recorta), .number (convierte a número), .lazy (actualiza en change, no en cada tecla). En componentes propios, defineModel() crea tu propio v-model:

vue
<script setup lang="ts">
const valor = defineModel<string>();          // el padre hace <MiInput v-model="x" />
</script>

19.6 · Comunicación entre componentes

props ↓  /  emits ↑          slots (contenido)         provide / inject
padre ──▶ hijo               <Card><p>hola</p></Card>   ancestro ──▶ descendiente
hijo ──▶ padre (eventos)     el hijo pinta <slot/>      (sin pasar por el medio)
vue
<!-- Slots: componentes contenedor reutilizables -->
<!-- BaseModal.vue -->
<template>
  <dialog open>
    <header><slot name="titulo">Sin título</slot></header>
    <slot />                                  <!-- slot por defecto -->
  </dialog>
</template>

<!-- Uso -->
<BaseModal>
  <template #titulo><h2>Confirmar borrado</h2></template>
  <p>¿Seguro?</p>
</BaseModal>
typescript
// provide / inject: el "Context" de Vue (tema, usuario…)
// ancestro:
provide('tema', tema);
// descendiente (a cualquier profundidad):
const tema = inject<Ref<'claro' | 'oscuro'>>('tema');

Composables: los "custom hooks" de Vue

typescript
// src/composables/useFetch.ts
import { ref } from 'vue';

export function useFetch<T>(url: string) {
  const datos = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const cargando = ref(true);

  fetch(url)
    .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
    .then((json) => (datos.value = json))
    .catch((e) => (error.value = e))
    .finally(() => (cargando.value = false));

  return { datos, error, cargando };
}

// Uso en cualquier componente:
const { datos: productos, error, cargando } = useFetch<Producto[]>('/api/productos');

💡 Para producción, igual que en React existe TanStack Query: @tanstack/vue-query (misma API con useQuery/useMutation) o VueUse, una colección de 200+ composables listos (useLocalStorage, useDebounce, useDark…) que es estándar de facto.


19.6bis · Teleport y Transition: modales y animaciones sin reinventar nada

Dos componentes integrados que resuelven problemas que, si no los conoces, acabas reimplementando mal.

<Teleport> — renderiza un trozo del template en OTRO punto del DOM (fuera del árbol de su padre), sin romper el estado ni la reactividad del componente. Imprescindible para modales: si el modal se queda anidado dentro de un contenedor con overflow: hidden o z-index bajo, se corta visualmente por mucho CSS que le pongas.

vue
<!-- BaseModal.vue -->
<template>
  <Teleport to="body">                     <!-- se monta como hijo directo de <body> -->
    <dialog ref="dialogRef" @close="emit('cerrar')">
      <header><slot name="titulo">Sin título</slot></header>
      <slot />
    </dialog>
  </Teleport>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps<{ abierto: boolean }>();
const emit = defineEmits<{ cerrar: [] }>();
const dialogRef = ref<HTMLDialogElement>();

watch(() => props.abierto, (abierto) => {
  abierto ? dialogRef.value?.showModal() : dialogRef.value?.close();
});
</script>

🧠 El componente lógicamente sigue "viviendo" donde lo declaraste (props, emits, estado intactos) — solo su HTML renderizado aparece en otro sitio del DOM. Es la misma idea que los portals de React, pero nativa del framework.

<Transition> / <TransitionGroup> — anima la entrada/salida de un elemento aplicando clases CSS automáticamente en cada fase, sin gestionar setTimeout a mano:

vue
<template>
  <Transition name="fade">
    <p v-if="mostrar">¡Guardado!</p>
  </Transition>

  <!-- Para listas: TransitionGroup anima cada item que entra/sale/se reordena -->
  <TransitionGroup name="lista" tag="ul">
    <li v-for="p in productos" :key="p.id">{{ p.nombre }}</li>
  </TransitionGroup>
</template>

<style>
/* Vue añade/quita estas clases automáticamente según la fase de la transición */
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

💡 Igual que en el apéndice M (animar solo transform/opacity para ir por GPU): las mismas reglas de rendimiento y de prefers-reduced-motion aplican aquí. <Transition> es el mecanismo; las buenas prácticas de animación son las mismas del resto del libro.


19.7 · Rutas: Vue Router 4

typescript
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: () => import('@/views/InicioView.vue') },
    { path: '/productos', component: () => import('@/views/ProductosView.vue') },
    { path: '/productos/:id', component: () => import('@/views/DetalleView.vue'), props: true },
    { path: '/:pathMatch(.*)*', component: () => import('@/views/NoEncontrado.vue') },
  ],
});

// Guard global: proteger rutas (como proxy.ts en Next)
router.beforeEach((to) => {
  if (to.meta.requiereAuth && !sesionActiva()) return '/login';
});

export default router;
vue
<template>
  <nav>
    <RouterLink to="/productos">Productos</RouterLink>  <!-- .router-link-active auto -->
  </nav>
  <RouterView />                                        <!-- aquí se pinta la ruta -->
</template>

<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();          // route.params.id, route.query.q
const router = useRouter();        // router.push('/productos')
</script>

Los () => import(...) hacen lazy loading por ruta: cada vista es un chunk aparte.


19.8 · Estado global: Pinia

Pinia es el store oficial (sucesor de Vuex). Un store = un composable global:

typescript
// src/stores/carrito.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useCarritoStore = defineStore('carrito', () => {
  // state
  const items = ref<Item[]>([]);

  // getters (computed)
  const total = computed(() => items.value.reduce((s, i) => s + i.precio * i.cantidad, 0));
  const numItems = computed(() => items.value.length);

  // actions
  function añadir(producto: Producto) {
    const existente = items.value.find((i) => i.id === producto.id);
    if (existente) existente.cantidad++;
    else items.value.push({ ...producto, cantidad: 1 });
  }
  function vaciar() { items.value = []; }

  return { items, total, numItems, añadir, vaciar };
});
vue
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCarritoStore } from '@/stores/carrito';

const carrito = useCarritoStore();
const { total, numItems } = storeToRefs(carrito);   // ⚠️ storeToRefs para no perder reactividad
</script>

<template>
  <span>🛒 {{ numItems }} — {{ total.toFixed(2) }} €</span>
  <button @click="carrito.vaciar()">Vaciar</button>
</template>

🧠 Misma regla que siempre: estado global solo para lo realmente compartido (carrito, sesión, tema). El resto, estado local del componente. Pinia se integra con las devtools de Vue: instala la extensión del navegador, es de lo mejor del ecosistema.


19.9 · Tests: Vitest + Vue Test Utils

typescript
// src/components/__tests__/FormProducto.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import FormProducto from '../FormProducto.vue';

describe('FormProducto', () => {
  it('emite "crear" con los datos del formulario', async () => {
    const wrapper = mount(FormProducto);

    await wrapper.get('input[placeholder="Nombre"]').setValue('Teclado');
    await wrapper.get('input[type="number"]').setValue(89.9);
    await wrapper.get('form').trigger('submit');

    expect(wrapper.emitted('crear')![0]).toEqual([{ nombre: 'Teclado', precio: 89.9 }]);
  });

  it('deshabilita el botón si el formulario es inválido', () => {
    const wrapper = mount(FormProducto);
    expect(wrapper.get('button').attributes('disabled')).toBeDefined();
  });
});

Para tests de stores: createTestingPinia(). Para el enfoque "testea como el usuario" del cap. 17: @testing-library/vue funciona igual (roles, user-event). E2E: Playwright, como siempre.

bash
npm run test:unit

19.10 · Vue 3.6: qué viene (y qué no tocar aún)

  • Reactividad alien-signals: el núcleo reescrito — hasta ~40% más rápido y menos memoria. Cero cambios en tu código.
  • Vapor Mode: <script setup vapor> compila el componente sin Virtual DOM — genera código que toca el DOM directo (como Solid/Svelte). Ideal para listas enormes y componentes calientes. Se puede mezclar: solo los componentes marcados son vapor.

⚠️ Advertencia práctica: cuando 3.6 salga estable, actualiza primero en una rama, corre tus tests, y adopta Vapor solo donde midas beneficio. No reescribas la app "porque es nuevo".


19.11 · Buenas prácticas Vue

  1. <script setup> + Composition API + TypeScript siempre. Options API solo para leer código legacy.
  2. ref por defecto; reactive solo si sabes por qué. Script → .value, template → sin él.
  3. Deriva con computed, no dupliques estado ni lo sincronices con watchers.
  4. watch es el último recurso — igual que useEffect en React: para efectos externos, no para orquestar estado.
  5. :key estable en cada v-for (jamás el índice si la lista cambia).
  6. Props down, emits up. Tipa ambos con defineProps / defineEmits.
  7. Composables para lógica reutilizable; mira VueUse antes de escribir el tuyo.
  8. Pinia solo para estado global real; storeToRefs al desestructurar.
  9. v-html nunca con datos de usuario (XSS, apéndice C).
  10. Estilos scoped por defecto en componentes.

✅ Ejercicio del capítulo

La tienda, versión Vue (compárala con tu versión React del cap. 17):

1. create-vue con TS + Router + Pinia + Vitest.
2. Rutas: /, /productos, /productos/:id (lazy), 404. Guard en /admin.
3. Lista de productos con @tanstack/vue-query contra tu API del libro:
   estados cargando/error/vacío + búsqueda con v-model + computed.
4. FormProducto con v-model (+ .trim/.number), validación computed y emits tipados.
5. Carrito en Pinia con persistencia via watchEffect + localStorage.
6. BaseModal con slots para confirmar borrados.
7. Modo oscuro con provide/inject (o useDark de VueUse).
8. Tests: FormProducto (emits), store del carrito, y lista con MSW.
9. Bonus: mide con las devtools por qué computed no recalcula si no cambia su dependencia.

🎉 Fin de la Parte V. Ya puedes construir el stack completo: API (Parte II) + frontend en cualquiera de los tres enfoques (HTMX, React/Next, Vue) + despliegue (Parte IV). Elige según el proyecto, no según la moda: esa es la marca de un fullstack de verdad.

💡 Pistas de la solución (abre solo si te atascas)
  • El punto 3: si cargando/error no reaccionan como esperas, revisa que estés leyendo isPending/error de @tanstack/vue-query como refs (con .value en el <script>, sin él en el <template>) — es el mismo despiste de "olvidar .value" de 19.4.
  • Para el punto 5, watchEffect corre automáticamente cada vez que lee carrito.value dentro — no necesitas listar dependencias a mano como en watch, pero sí asegurarte de que el guardado en localStorage está DENTRO del callback, no fuera.
  • El BaseModal del punto 6: usa <dialog> nativo con showModal()/close() — te da gestión de foco y cierre con Esc gratis, sin reimplementarlo (mismo consejo que el apéndice M).
  • El bonus (punto 9): un computed solo se recalcula si alguna de sus dependencias reactivas cambió de valor; ábrelo en las Vue DevTools y observa el contador de recomputaciones al tocar algo que NO está dentro del computed.

🧠 Autoevaluación

Vue desenvuelve automáticamente los ref dentro del <template> — es una conveniencia del compilador de plantillas. En el <script>, donde no hay ese desenvolvimiento automático, sigues necesitando .value para leer o escribir el valor real dentro del ref.

El sistema de reactividad de Vue (Proxies) detecta la mutación directa del array y notifica a los componentes que lo usan — mutar es el patrón esperado. React compara referencias entre renders: mutar el array original no cambia la referencia, así que React no se entera del cambio y no re-renderiza (cap. 17.4).

Siempre que el valor se pueda calcular a partir de otro estado ya existente (el total del carrito a partir de sus items). Un computed se recalcula solo cuando cambian sus dependencias y se cachea entre medias; duplicarlo en un ref aparte obliga a mantenerlo sincronizado a mano y es una fuente de bugs cuando alguien olvida actualizarlo.

Al desestructurar directamente un store de Pinia se pierde la reactividad — total queda como un valor plano congelado en ese instante. storeToRefs(useCarritoStore()) convierte las propiedades reactivas del store en refs individuales que sí mantienen la conexión con el store original.

Porque un modal anidado dentro de un contenedor con overflow: hidden, position: relative o un z-index bajo puede cortarse visualmente o quedar detrás de otros elementos sin que ningún CSS del propio modal lo arregle. Teleport renderiza ese HTML como hijo directo de <body> mientras el componente sigue con su estado y props intactos donde lo declaraste.


Siguiente: 09-testing.md si aún no lo leíste — o vuelve al README.md para elegir tu siguiente apéndice.