🎯 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 | |
|---|---|---|
| UI | JSX (JS que genera HTML) | Plantillas HTML con directivas |
| Reactividad | Re-ejecuta el componente al cambiar estado | Rastrea dependencias: actualiza solo lo afectado |
| Estado local | useState + inmutabilidad estricta | ref() — puedes mutar tranquilamente |
| Memoización | React Compiler | No hace falta: computed cachea solo |
| Curva | Media (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
setupcorre 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
npm create vue@latest tienda-vue
# ✔ TypeScript ✔ Vue Router ✔ Pinia ✔ Vitest ✔ ESLint
cd tienda-vue && npm install && npm run dev # http://localhost:5173Todo componente es un SFC (Single File Component), un .vue con tres bloques:
<!-- 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 @:
<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 usathis., es del estilo viejo.
19.3 · Las directivas del template
| Directiva | Atajo | Qué hace |
|---|---|---|
v-bind:src="x" | :src="x" | Atributo dinámico |
v-on:click="f" | @click="f" | Escuchar evento |
v-if / v-else-if / v-else | Renderizado condicional (monta/desmonta) | |
v-show | Condicional con CSS display (para toggles frecuentes) | |
v-for="p in lista" | Bucles (siempre con :key) | |
v-model | Binding bidireccional en formularios | |
v-html | ⚠️ HTML crudo — riesgo XSS, solo contenido propio |
<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
<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
.valueen el script (o ponerlo en el template). Regla: script →.value; template → sin él. También existereactive()para objetos (sin.value), pero pierde reactividad al desestructurar: usarefpor defecto y evitas sustos.
computed — valores derivados con caché automática
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)
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
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):
<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:
<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)<!-- 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>// 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
// 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 conuseQuery/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.
<!-- 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:
<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/opacitypara ir por GPU): las mismas reglas de rendimiento y deprefers-reduced-motionaplican 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
// 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;<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:
// 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 };
});<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
// 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.
npm run test:unit19.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
<script setup>+ Composition API + TypeScript siempre. Options API solo para leer código legacy.refpor defecto;reactivesolo si sabes por qué. Script →.value, template → sin él.- Deriva con
computed, no dupliques estado ni lo sincronices con watchers. watches el último recurso — igual queuseEffecten React: para efectos externos, no para orquestar estado.:keyestable en cadav-for(jamás el índice si la lista cambia).- Props down, emits up. Tipa ambos con
defineProps/defineEmits. - Composables para lógica reutilizable; mira VueUse antes de escribir el tuyo.
- Pinia solo para estado global real;
storeToRefsal desestructurar. v-htmlnunca con datos de usuario (XSS, apéndice C).- Estilos
scopedpor 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/errorno reaccionan como esperas, revisa que estés leyendoisPending/errorde@tanstack/vue-querycomo refs (con.valueen el<script>, sin él en el<template>) — es el mismo despiste de "olvidar.value" de 19.4. - Para el punto 5,
watchEffectcorre automáticamente cada vez que leecarrito.valuedentro — no necesitas listar dependencias a mano como enwatch, pero sí asegurarte de que el guardado en localStorage está DENTRO del callback, no fuera. - El BaseModal del punto 6: usa
<dialog>nativo conshowModal()/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
computedsolo 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 delcomputed.
🧠 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.