Hasta ahora has visto la misma pantalla de Nómada Tareas escrita en JavaScript puro y en React, y las dos versiones compartían un modelo: una función que se vuelve a ejecutar entera y un motor que compara el resultado con el anterior. Vue cambia eso de raíz. Su reactividad es de grano fino: no compara árboles, sino que registra qué expresión lee qué dato y actualiza solo lo que depende de lo que ha cambiado. Es la familia 2 de 10-01, en su versión más pulida y accesible. Y cambia también la unidad de trabajo: en lugar de repartir una tarjeta entre un <template>, un fichero CSS y dos módulos JavaScript, Vue la agrupa entera en un Componente de un Solo Fichero, con su plantilla, su lógica y sus estilos con ámbito propio. En esta lección verás por qué se le llama framework progresivo y cómo se añade tanto a una página existente como a un proyecto completo; la anatomía de un componente con <template>, <script setup> y <style scoped>; toda la sintaxis de plantilla —interpolación, v-bind, v-on, v-if frente a v-show, v-for con :key (el mismo data-id de siempre) y v-model explicado como azúcar y no como magia—; la reactividad de verdad, con ref, reactive, por qué existe .value, cómo los proxies detectan las dependencias y dónde se pierde la reactividad —el error que más desconcierta—; computed comparado con tu caché por versión de 09-02 y con useMemo; watch y watchEffect con el criterio para elegir; el ciclo de vida con onMounted y onUnmounted, que vuelve a ser tu destruir(); la comunicación con props, emits, slots y provide/inject; los composables como equivalente de los hooks personalizados; Pinia en su versión mínima; y el ecosistema oficial. Al final, la lista de tareas con su filtro reimplementada en Vue y comparada con las dos versiones anteriores.

Contenido

  1. Qué significa «framework progresivo»
  2. Añadir Vue a una página existente
  3. Un proyecto completo con Vite
  4. Componentes de un Solo Fichero
  5. Por qué agrupar plantilla, lógica y estilos
  6. La plantilla declarativa: interpolación
  7. v-bind: atributos dinámicos
  8. v-on: eventos
  9. v-if, v-else y v-show
  10. v-for y :key
  11. v-model: la doble vinculación explicada como azúcar
  12. Reactividad: ref y por qué existe .value
  13. reactive y cuándo usar cada uno
  14. Cómo detectan las dependencias los proxies
  15. Los límites de la reactividad: dónde se pierde
  16. computed: derivados con caché
  17. watch y watchEffect
  18. Cuándo usar computed, watch o watchEffect
  19. El ciclo de vida: onMounted y onUnmounted
  20. Comunicación: props y emits
  21. slots: composición de contenido
  22. provide e inject
  23. Composables: usarTablero y usarFiltroResponsable
  24. Estado global con Pinia
  25. El ecosistema oficial y la fragmentación
  26. Nómada Tareas en Vue: la lista completa
  27. Comparación con React y con JavaScript puro
  28. Errores Comunes y Consejos
  29. Ejercicios
  30. Conclusión

  1. Qué significa «framework progresivo»

Vue se define a sí mismo como un framework progresivo, y esa etiqueta tiene un significado técnico concreto: se puede adoptar por capas, empezando por la más pequeña, sin comprometerse con las demás.

Nivel de adopción Qué usas Qué necesitas
1 · Mejorar una página existente Vue desde una etiqueta <script>, controlando un <div> Nada: ni compilación, ni npm
2 · Componentes en varias páginas Vue + Componentes de un Solo Fichero Vite
3 · Aplicación de una sola página + Vue Router Vite
4 · Aplicación grande + Pinia, pruebas, TypeScript La cadena completa
5 · Renderizado en servidor + Nuxt El meta-framework

Esa gradación no existe en Angular, que exige toda la plataforma desde el principio, y en React solo existe a medias (necesitas compilación para JSX desde el primer minuto, salvo que renuncies a él). Es la razón por la que Vue aparece tanto en proyectos que empezaron sin framework y crecieron: se pueden convertir por trozos.

Para Nómada Tareas eso significa algo muy concreto. Podrías coger la aplicación actual —tu index.html, tu Tablero, tus utilidades— y convertir solo la lista de tareas en un componente Vue, dejando el resto intacto. No es un ejercicio teórico: es una estrategia de migración real, y la retomarás en 10-06.

  1. Añadir Vue a una página existente

El nivel 1, sin ninguna herramienta:

<div id="tablero">
  <label>
    Responsable:
    <select v-model="responsable">
      <option :value="null">Todos</option>
      <option v-for="n in responsables" :key="n" :value="n">{{ n }}</option>
    </select>
  </label>

  <p>{{ visibles.length }} de {{ tareas.length }} tareas · {{ horasAbiertas }} h abiertas</p>

  <ul>
    <li v-for="tarea in visibles" :key="tarea.id">
      {{ tarea.titulo }} — {{ tarea.responsable }} ({{ tarea.horasEstimadas }} h)
    </li>
  </ul>
</div>

<script type="module">
  import { createApp, ref, computed } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
  import { BACKLOG } from './js/datos/backlog.js';

  createApp({
    setup() {
      const tareas = ref(BACKLOG);
      const responsable = ref(null);

      const responsables = computed(() =>
        [...new Set(tareas.value.map((t) => t.responsable).filter(Boolean))].sort()
      );

      const visibles = computed(() =>
        responsable.value === null
          ? tareas.value
          : tareas.value.filter((t) => t.responsable === responsable.value)
      );

      const horasAbiertas = computed(() =>
        visibles.value.filter((t) => t.estado !== 'hecha')
                      .reduce((s, t) => s + t.horasEstimadas, 0)
      );

      return { tareas, responsable, responsables, visibles, horasAbiertas };
    }
  }).mount('#tablero');
</script>

Merece la pena detenerse aquí, porque este bloque ya contiene casi todos los conceptos de la lección y funciona abriendo el HTML en el navegador, sin npm install, sin compilar y sin configurar nada. Fíjate en tres cosas:

  • La plantilla es HTML válido. v-for, :value y {{ }} son atributos y texto; el navegador los ignora hasta que Vue los interpreta. Esto tiene una consecuencia práctica importante: el marcado se puede escribir en cualquier editor de HTML, y una persona de diseño lo entiende.
  • Vue solo controla #tablero. El resto de la página sigue siendo tuyo. Podrías tener tres islas Vue independientes en la misma página junto a tu JavaScript de siempre.
  • BACKLOG es tu módulo de datos, sin adaptar. Vue no tiene opinión sobre de dónde salen los objetos.

Para producción se usaría una versión compilada en lugar de la que interpreta plantillas en el navegador, pero el modelo mental es este.

  1. Un proyecto completo con Vite

Para el resto de la lección usaremos el nivel 2 en adelante:

npm create vue@latest nomada-vue
cd nomada-vue
npm install
npm run dev

El asistente pregunta si quieres TypeScript, Vue Router, Pinia, pruebas con Vitest y Playwright o Cypress. Esa lista es reveladora: todo el ecosistema esencial es oficial, mantenido por el mismo equipo y con documentación integrada. Es lo contrario de la tabla de decisiones de 10-02.

El punto de entrada:

// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import './estilos.css';

createApp(App).mount('#raiz');

  1. Componentes de un Solo Fichero

El fichero .vue es la unidad de trabajo de Vue: plantilla, lógica y estilos en el mismo sitio.

<!-- src/componentes/TarjetaTarea.vue -->
<script setup>
import { computed } from 'vue';
import { SIGUIENTE, ETIQUETA, estaVencida } from '../dominio/reglas.js';

const props = defineProps({
  tarea: { type: Object, required: true }
});

const emit = defineEmits(['avanzar']);

const vencida = computed(() => estaVencida(props.tarea));
const siguiente = computed(() => SIGUIENTE[props.tarea.estado]);
</script>

<template>
  <li
    class="tarea"
    :class="[
      `tarea--${tarea.prioridad}`,
      { 'tarea--hecha': tarea.estado === 'hecha', 'tarea--vencida': vencida }
    ]"
    :data-id="tarea.id"
  >
    <h3 class="tarea__titulo">{{ tarea.titulo }}</h3>

    <p class="tarea__meta">
      {{ tarea.responsable ?? 'sin asignar' }} · {{ tarea.horasEstimadas }} h
      <span v-if="vencida" class="tarea__aviso"> · ⚠ vencida</span>
    </p>

    <ul class="tarea__etiquetas">
      <li v-for="etiqueta in tarea.etiquetas" :key="etiqueta" class="etiqueta">
        {{ etiqueta }}
      </li>
    </ul>

    <button
      type="button"
      :disabled="siguiente === null"
      :aria-label="`${ETIQUETA[tarea.estado]}: ${tarea.titulo}`"
      @click="emit('avanzar', tarea.id)"
    >
      {{ ETIQUETA[tarea.estado] }}
    </button>
  </li>
</template>

<style scoped>
.tarea { border-left: 4px solid var(--borde); padding: 0.75rem; }
.tarea--alta { border-left-color: var(--rojo); }
.tarea--hecha .tarea__titulo { text-decoration: line-through; opacity: 0.6; }
.tarea--vencida { background: var(--aviso-suave); }
.tarea__etiquetas { list-style: none; display: flex; gap: 0.3rem; padding: 0; }
</style>

Tres bloques, con reglas propias:

  • <script setup> es azúcar de compilación. Todo lo que declares en el nivel superior —variables, funciones, importaciones— queda disponible en la plantilla automáticamente, sin return. Es la forma actual y recomendada de escribir componentes Vue; verás código antiguo con export default { setup() { … return { … } } } o con data()/methods (la API de opciones), que siguen funcionando pero ya no son la primera opción.
  • <template> contiene HTML con directivas. Se compila a una función de renderizado durante la construcción.
  • <style scoped> aplica los estilos solo a este componente. El compilador añade un atributo único a los elementos del componente y otro a los selectores, de modo que .tarea de aquí no puede afectar a un .tarea de otro sitio.

  1. Por qué agrupar plantilla, lógica y estilos

Ese scoped merece un párrafo propio, porque es donde Vue resuelve el problema 7 de 10-01 de forma más directa que React.

Recuerda tu tarjeta en Nómada Tareas: el <template id="plantilla-tarea"> en index.html, las clases .tarea__* en css/estilos.css, la lógica en js/vista/tarjeta.js y el comportamiento en js/vista/controlador.js. Cuatro ficheros, cuatro acoplamientos y ninguno comprobado. Renombrar .tarea__titulo en el CSS no produce ningún error: simplemente la tarjeta se ve mal.

Con un fichero .vue, los cuatro están a la vista en la misma pantalla. Cambiar el nombre de una clase es una operación de buscar y reemplazar dentro de un fichero de 40 líneas. Y como los estilos tienen ámbito, no hay que inventar convenciones de nombres (BEM y similares) para evitar colisiones: el aislamiento es real, no una disciplina.

La objeción clásica —«mezclar HTML, CSS y JavaScript es mala práctica»— confunde dos cosas. La separación de intereses no es separación de tecnologías: es separación de responsabilidades. La estructura, el aspecto y el comportamiento de una tarjeta son un solo interés, y repartirlos por tres ficheros no los desacopla, solo los aleja. Es exactamente el argumento del apartado 12 de 10-01 sobre el componente como unidad correcta de reutilización.

  1. La plantilla declarativa: interpolación

El texto dinámico se escribe con dobles llaves:

<h3>{{ tarea.titulo }}</h3>
<p>{{ tarea.horasEstimadas }} h · {{ tarea.horasEstimadas * PESOS[tarea.prioridad] }} de esfuerzo</p>
<p>{{ tarea.responsable ?? 'sin asignar' }}</p>

Dentro va una expresión JavaScript, no una sentencia — la misma regla que las llaves de JSX. Y el contenido se escapa siempre: es textContent, no innerHTML, con la misma protección de 06-02.

Diferencia con React que conviene fijar: en JSX escribes {} para todo lo dinámico, incluidos los atributos. En Vue, las llaves son solo para texto; los atributos usan v-bind.

  1. v-bind: atributos dinámicos

<!-- Forma larga y forma abreviada, idénticas -->
<li v-bind:data-id="tarea.id">…</li>
<li :data-id="tarea.id">…</li>

<button :disabled="siguiente === null">…</button>
<img :src="tarea.imagen" :alt="`Foto de ${tarea.titulo}`">

Dos casos especiales que se usan constantemente:

Clases. :class acepta una cadena, un objeto (clave = clase, valor = condición) o un array, y se combina con el atributo class estático:

<li
  class="tarea"
  :class="[
    `tarea--${tarea.prioridad}`,
    { 'tarea--hecha': tarea.estado === 'hecha', 'tarea--vencida': vencida }
  ]"
>

Compara con lo que hacías en pintarTarjeta:

li.classList.remove(...CLASES_PRIORIDAD);
li.classList.add(`tarea--${tarea.prioridad}`);
li.classList.toggle('tarea--hecha', tarea.estado === 'hecha');
li.classList.toggle('tarea--vencida', vencida);

Cuatro instrucciones imperativas que hay que mantener sincronizadas —incluido el remove previo, que es fácil olvidar— frente a una descripción declarativa. Y frente a React, donde hay que construir la cadena a mano con plantillas o una utilidad, la sintaxis de objeto de Vue es notablemente más limpia.

Estilos. :style acepta un objeto con propiedades en camelCase:

<div class="barra" :style="{ width: `${porcentaje}%`, backgroundColor: color }"></div>

  1. v-on: eventos

<!-- Forma larga y abreviada -->
<button v-on:click="avanzar(tarea.id)">Empezar</button>
<button @click="avanzar(tarea.id)">Empezar</button>

<!-- Con el objeto de evento -->
<select @change="alCambiar($event.target.value || null)">
<input @input="texto = $event.target.value">

La diferencia visible con React es que en la plantilla puedes escribir la llamada directamente (avanzar(tarea.id)), sin envolverla en una función flecha. El compilador se encarga: @click="avanzar(tarea.id)" se convierte en un manejador que ejecuta esa expresión. Con @click="avanzar" (sin paréntesis) se pasa la función y recibe el evento como argumento.

Vue añade modificadores, que resuelven de forma declarativa cosas que en JavaScript puro escribes a mano:

Modificador Equivalente en JavaScript puro
@submit.prevent evento.preventDefault()
@click.stop evento.stopPropagation()
@click.self if (evento.target !== evento.currentTarget) return
@click.once addEventListener(…, { once: true })
@scroll.passive addEventListener(…, { passive: true })
@keyup.enter if (evento.key === 'Enter')
@keyup.esc if (evento.key === 'Escape')
<form @submit.prevent="crearTarea">…</form>
<input @keyup.enter="buscar" @keyup.esc="limpiar">

Todos ellos te resultarán familiares de 06-03 y 06-07. Son atajos, no funcionalidad nueva, pero eliminan una clase entera de olvidos — sobre todo el preventDefault de los formularios.

Nota sobre delegación: como en React, escribir @click en cada uno de 600 elementos no crea 600 escuchas del DOM. El compilador genera manejadores y Vue los gestiona de forma eficiente. El patrón closest('[data-accion]') de 06-04 sigue siendo válido si lo necesitas, pero ya no es obligatorio.

  1. v-if, v-else y v-show

<p v-if="visibles.length === 0" class="lista-vacia">
  Ninguna tarea coincide con el filtro.
</p>
<ul v-else class="lista-tareas">
  <li v-for="tarea in visibles" :key="tarea.id">…</li>
</ul>

Y la comparación que hay que tener clara:

v-if v-show
Qué hace Crea o destruye el elemento del DOM Lo crea siempre y alterna display: none
Coste al alternar Alto: montar y desmontar Muy bajo: una propiedad CSS
Coste inicial si es falso Cero: no se crea nada Se crea igualmente
Ciclo de vida del componente Se dispara onMounted/onUnmounted No se dispara nada
Nodos en el DOM Solo los visibles Todos, siempre
Encontrable con Ctrl+F No (está en el DOM, oculto)
Cuándo usarlo Condición que cambia poco, o contenido caro Alternancia muy frecuente de algo ligero

Esta tabla es la formalización de algo que ya mediste en 09-04: ocultar 594 tareas con hidden mantenía 7.812 nodos en el DOM, mientras que eliminarlas dejaba 1.194. v-if es lo segundo, v-show lo primero. Para el filtro de responsable, v-if es lo correcto.

Vue permite además agrupar sin añadir un nodo:

<template v-if="cargando">
  <p role="status">Cargando tareas…</p>
  <div class="esqueleto" aria-hidden="true"></div>
</template>

Es el equivalente del fragmento <>…</> de React.

  1. v-for y :key

<ul class="lista-tareas">
  <li v-for="tarea in visibles" :key="tarea.id" class="tarea">
    {{ tarea.titulo }}
  </li>
</ul>

<!-- Con índice -->
<li v-for="(tarea, indice) in visibles" :key="tarea.id">
  {{ indice + 1 }}. {{ tarea.titulo }}
</li>

<!-- Sobre un objeto -->
<li v-for="(cantidad, estado) in resumenPorEstado" :key="estado">
  {{ estado }}: {{ cantidad }}
</li>

Y aquí está, por tercera vez en el módulo, el mismo concepto: :key es tu data-id de reconciliar (06-06) y la key de React (10-02). Vue lo usa exactamente igual: construye un mapa de clave a nodo, empareja por clave y no por posición, reutiliza los que siguen vivos y elimina los sobrantes.

Las mismas tres reglas: estable, única entre hermanos, nunca el índice si la lista se mueve. Y una ventaja pequeña pero útil de Vue: la regla de ESLint del complemento oficial marca como error un v-for sin :key, mientras que en React es solo un aviso en la consola. Es una decisión de framework «con más opiniones», en el sentido de 10-01.

Un detalle que Vue hace mejor que el DOM virtual puro: como el compilador analiza la plantilla, sabe qué partes son estáticas y las salta por completo en las actualizaciones. Si tu <li> tiene diez elementos y solo uno depende de datos, Vue actualiza uno. React ejecuta la función entera y compara los diez. Es la mezcla de familias 2 y 3 de 10-01 en acción.

  1. v-model: la doble vinculación explicada como azúcar

v-model es la característica más famosa de Vue y la peor entendida. Se presenta como «doble vinculación» y suena a magia bidireccional. No lo es: es azúcar sintáctico de dos cosas que ya sabes hacer.

<input v-model="texto">

se compila, esencialmente, en:

<input :value="texto" @input="texto = $event.target.value">

Es decir: un v-bind que baja el valor y un v-on que lo sube. Exactamente el formulario controlado de 10-02, escrito en una línea en lugar de dos. No hay canal mágico ni observación del DOM: hay un atributo y un evento.

Vue adapta la pareja según el elemento, que es lo que ahorra trabajo de verdad:

Elemento Propiedad que enlaza Evento que escucha
<input type="text"> value input
<textarea> value input
<input type="checkbox"> checked change
<input type="radio"> checked change
<select> value change
<select multiple> array de valores change

Con modificadores útiles:

<input v-model.trim="titulo">       <!-- aplica .trim(): útil para la R2 -->
<input v-model.number="horas">      <!-- convierte a número: sin él, "6" es cadena -->
<input v-model.lazy="busqueda">     <!-- escucha `change` en vez de `input` -->

v-model.number merece una nota, porque conecta con 01-07: sin él, <input type="number"> devuelve una cadena, y horas > 40 compararía texto. Es uno de los errores más frecuentes al validar la R3.

En componentes propios, v-model funciona sobre una prop y un evento:

<!-- Componente hijo: FiltroResponsable.vue -->
<script setup>
defineProps({ modelValue: { type: String, default: null } });
defineEmits(['update:modelValue']);
</script>

<template>
  <select :value="modelValue" @change="$emit('update:modelValue', $event.target.value || null)">
    <option value="">Todos</option>
    <slot />
  </select>
</template>
<!-- Padre -->
<FiltroResponsable v-model="responsable" />

Otra vez: una prop que baja y un evento que sube. El flujo de datos sigue siendo unidireccional; v-model solo pone nombre al patrón. Entenderlo así evita el malentendido de creer que el hijo modifica el estado del padre — no lo hace: le pide que lo modifique.

  1. Reactividad: ref y por qué existe .value

Llegamos al corazón de Vue.

import { ref } from 'vue';

const responsable = ref(null);

console.log(responsable.value);    // null
responsable.value = 'Iván';        // ← esto dispara la actualización de todo lo que dependa

La pregunta que todo el mundo hace al principio: ¿por qué .value?

La respuesta está en una limitación del lenguaje que conoces desde 01-05: JavaScript no permite interceptar la asignación a una variable. No hay forma de que responsable = 'Iván' ejecute código. Se puede interceptar el acceso a una propiedad de un objeto —eso es lo que hacen los getter/setter de 05-03 y los Proxy—, pero no a una variable suelta.

Así que Vue hace lo único posible: ref(null) devuelve un objeto con una única propiedad, value, cuyo get y set están interceptados.

// Lo que es un ref, conceptualmente (05-03: getters y setters)
function ref(inicial) {
  let interno = inicial;
  const suscriptores = new Set();

  return {
    get value() {
      registrarDependencia(suscriptores);    // ← quien lee, se apunta
      return interno;
    },
    set value(nuevo) {
      if (Object.is(interno, nuevo)) return;  // sin cambio, sin trabajo
      interno = nuevo;
      notificar(suscriptores);                // ← quien leyó, se entera
    }
  };
}

Ese .value que parece una molestia es, literalmente, el precio de que exista el mecanismo. Y hay una compensación importante: en la plantilla se desenvuelve solo. Dentro de <template> escribes {{ responsable }}, no {{ responsable.value }}, porque el compilador sabe qué variables son refs y añade el .value por ti.

Comparación directa con lo que ya conoces:

useState de React ref de Vue
Leer responsable responsable.value (en plantilla: responsable)
Escribir setResponsable('Iván') responsable.value = 'Iván'
Qué provoca Reejecutar el componente entero Actualizar solo lo que lee ese ref
Se puede escribir desde fuera del componente No : un ref es un objeto normal
Comparación de cambio Object.is sobre el valor Object.is sobre el valor

La tercera fila es la diferencia fundamental. Cambiar responsable.value no reejecuta App: reejecuta las expresiones concretas que leyeron ese ref. Ese es el grano fino de 10-01.

Y la cuarta tiene una consecuencia práctica muy útil: un ref se puede crear en un módulo, exportar y modificar desde cualquier sitio —incluido tu CanalTablero de 07-04 al recibir un mensaje del WebSocket— sin estar dentro de ningún componente.

  1. reactive y cuándo usar cada uno

reactive es la alternativa para objetos: envuelve el objeto entero en un Proxy y no necesita .value.

import { reactive } from 'vue';

const filtros = reactive({ responsable: null, texto: '', orden: 'prioridad' });

filtros.responsable = 'Iván';    // sin .value: reactivo directamente
filtros.texto = 'serigrafía';

Es más cómodo, pero tiene cuatro limitaciones reales:

Limitación Consecuencia
Solo funciona con objetos, arrays, Map y Set Un número o una cadena no pueden ser reactive
No se puede reemplazar el objeto entero filtros = {…} rompe la reactividad; hay que asignar propiedad a propiedad
Se pierde al desestructurar const { responsable } = filtros copia el valor, no el vínculo
Se pierde al pasar una propiedad primitiva a una función Se pasa el valor, no la referencia

La recomendación actual, y la que sigue casi todo el código Vue moderno: usa ref por defecto. Funciona con cualquier tipo, se puede reemplazar entero, y el .value explícito hace visible dónde está la reactividad. reactive queda para objetos de estado agrupado cuyas propiedades se manipulan una a una, y aun así muchos equipos prefieren un ref con un objeto dentro:

const filtros = ref({ responsable: null, texto: '' });
filtros.value.responsable = 'Iván';           // reactivo
filtros.value = { responsable: null, texto: '' };  // reemplazo completo, también reactivo

  1. Cómo detectan las dependencias los proxies

Toca explicar el mecanismo de verdad, porque de él salen todos los comportamientos —los buenos y los raros.

Vue mantiene, mientras ejecuta un efecto reactivo (el render de un componente, un computed, un watchEffect), una referencia al efecto actual. Cuando durante esa ejecución se lee una propiedad reactiva, el Proxy intercepta la lectura y registra: «este efecto depende de esta propiedad de este objeto». Cuando alguien escribe esa propiedad, el proxy busca la lista de efectos dependientes y los vuelve a ejecutar.

graph TD
  E["Efecto reactivo<br/>(render, computed, watchEffect)"] --> L["Lee tarea.estado"]
  L --> P["Proxy intercepta el get"]
  P --> R["Registra: efecto depende de<br/>(objetoTarea, 'estado')"]
  W["Alguien escribe tarea.estado = 'hecha'"] --> P2["Proxy intercepta el set"]
  P2 --> B["Busca los efectos dependientes<br/>de (objetoTarea, 'estado')"]
  B --> E2["Los vuelve a ejecutar"]

Tres consecuencias que explican todo lo demás:

1 · La detección es automática y precisa. No declaras dependencias como en el array de useEffect: se descubren al ejecutar. Eso elimina de un plumazo la clase entera de errores «falta una dependencia» y «esta dependencia cambia siempre» de 10-02.

2 · La detección es dinámica. Si un computed tiene un if, las dependencias de la rama no tomada no se registran en esta ejecución. Es correcto y es lo que quieres: si el resultado no depende de un dato, cambiar ese dato no debe recalcular nada.

3 · La detección solo funciona durante la ejecución del efecto. Si lees un valor reactivo dentro de un setTimeout, un .then() o un addEventListener, esa lectura ocurre después, cuando el efecto ya terminó y el registro está cerrado. No se registra nada. Esta es la causa de la mitad de los «esto no se actualiza» de Vue.

// ❌ La lectura ocurre fuera del contexto de seguimiento
watchEffect(() => {
  setTimeout(() => {
    console.log(responsable.value);   // no registra dependencia
  }, 100);
});

// ✅ La lectura ocurre dentro
watchEffect(() => {
  const actual = responsable.value;    // ← se registra aquí
  setTimeout(() => console.log(actual), 100);
});

  1. Los límites de la reactividad: dónde se pierde

Este apartado es el que separa a quien sabe Vue de quien lo ha probado. La reactividad se pierde en cuatro situaciones, y todas tienen la misma causa: el vínculo vive en la propiedad, no en el valor.

Límite 1 · Desestructurar un objeto reactivo.

const filtros = reactive({ responsable: null, texto: '' });

// ❌ `responsable` es una copia del valor, sin vínculo
const { responsable } = filtros;
console.log(responsable);      // null, y seguirá siendo null para siempre

// ✅ toRefs mantiene el vínculo convirtiendo cada propiedad en un ref
import { toRefs } from 'vue';
const { responsable: refResponsable } = toRefs(filtros);
console.log(refResponsable.value);   // sigue vinculado

toRefs recorre el objeto y crea, para cada propiedad, un ref cuyo get/set apuntan a la propiedad original. Es la herramienta específica para desestructurar sin romper nada, y se usa mucho al devolver el estado desde un composable.

Límite 2 · Desestructurar las props.

const props = defineProps({ tarea: Object });

const { tarea } = props;      // ❌ pierde la reactividad
// ✅ usar props.tarea directamente, o toRefs(props)

Con un matiz importante: <script setup> tiene una transformación del compilador que hace reactiva la desestructuración de props escrita directamente en defineProps. Fuera de ese caso concreto, la regla se mantiene.

Límite 3 · Pasar un primitivo a una función.

const contador = ref(0);

function incrementar(valor) { valor++; }      // ❌ recibe una copia del número
incrementar(contador.value);                   // no hace nada

function incrementarRef(ref) { ref.value++; }  // ✅ recibe el objeto ref
incrementarRef(contador);

Este es el motivo profundo por el que ref es un objeto: los objetos se pasan por referencia y los primitivos por valor, exactamente como aprendiste en 01-05 y 04-08. ref convierte un primitivo en algo que se puede pasar sin perderlo.

Límite 4 · Reemplazar un objeto reactive entero.

let filtros = reactive({ responsable: null });
filtros = reactive({ responsable: 'Iván' });   // ❌ la plantilla sigue apuntando al primero

La comparación honesta con React, que es la contrapartida anunciada en 10-01:

React Vue
Declarar dependencias A mano, en un array Automático, al leer
Error típico Dependencia que falta u objeto que cambia siempre Reactividad perdida al desestructurar o al leer fuera del efecto
Cuándo se detecta el error ESLint avisa En ejecución, sin aviso: «no se actualiza»
Modelo mental Simple, verboso Sutil, conciso

Ninguno de los dos es «más fácil». React te obliga a declarar y por eso puede comprobarlo con una regla de ESLint; Vue lo hace solo y por eso el fallo es silencioso. Es un intercambio real.

  1. computed: derivados con caché

computed declara un valor derivado de otros valores reactivos:

import { ref, computed } from 'vue';

const tareas = ref(BACKLOG);
const responsable = ref(null);

const visibles = computed(() =>
  responsable.value === null
    ? tareas.value
    : tareas.value.filter((t) => t.responsable === responsable.value)
);

const horasAbiertas = computed(() =>
  visibles.value.filter((t) => t.estado !== 'hecha')
                .reduce((s, t) => s + t.horasEstimadas, 0)
);

Cuatro propiedades:

  1. Se cachea. El cálculo solo se ejecuta si alguna dependencia cambió. Leer visibles.value cien veces sin cambios ejecuta el filtro una vez.
  2. Se compone. horasAbiertas depende de visibles, que depende de tareas y responsable. Vue construye el grafo y propaga en el orden correcto, sin recalcular nada dos veces.
  3. Es perezoso. Si nadie lee horasAbiertas, no se calcula, aunque cambie tareas.
  4. Es de solo lectura por defecto (se puede definir un set, pero es raro y casi siempre indica un diseño mejorable).

Y aquí está el paralelismo que cierra 09-02:

// Tu caché por versión de 09-02
resumen(hoy = HOY) {
  if (this.#cacheResumen?.version === this.#version && this.#cacheResumen.hoy === hoy) {
    return this.#cacheResumen.valor;
  }
  const valor = this.#calcularResumen(hoy);
  this.#cacheResumen = { version: this.#version, hoy, valor };
  return valor;
}
// Lo mismo, en Vue
const resumen = computed(() => calcularResumen(tareas.value, hoy.value));

Escribiste veinte líneas —contador de versión, invalidación en cada mutación, comparación de la clave de caché— y el riesgo permanente de olvidar un #invalidar(). computed hace lo mismo, con la invalidación descubierta automáticamente y sin posibilidad de olvido.

Comparación con useMemo de React, que es el equivalente más cercano:

useMemo (React) computed (Vue)
Dependencias Declaradas a mano Detectadas automáticamente
Riesgo de error Olvidar una dependencia Leer fuera del contexto de seguimiento
Cuándo se usa Como optimización, cuando mides que duele Como forma natural de escribir derivados
Se puede usar fuera de un componente No
Coste si sobra Comparar el array de dependencias Mínimo

La tercera fila es la diferencia cultural entre los dos frameworks. En React, useMemo es la excepción y calcular directamente es la norma. En Vue, computed es la norma: cualquier derivado se declara así, sin pensar en rendimiento, porque es la forma correcta de expresarlo. Y por eso en Vue casi nunca se ve el debate sobre memoización excesiva de 10-02.

  1. watch y watchEffect

Los dos hacen efectos secundarios cuando algo cambia. Son el equivalente de useEffect, con la misma advertencia: no son para derivar valores —para eso está computed.

watch observa una fuente concreta y recibe el valor nuevo y el anterior:

import { watch } from 'vue';

watch(responsable, (nuevo, anterior) => {
  console.log(`Filtro: ${anterior} → ${nuevo}`);
  actualizarUrl({ responsable: nuevo });        // History API, 07-06
});

// Varias fuentes
watch([responsable, texto], ([r, t]) => guardarPreferencias({ r, t }));

// Un getter, para observar una propiedad concreta
watch(() => props.tarea.estado, (nuevo) => {
  if (nuevo === 'hecha') anunciar(`${props.tarea.titulo} completada`);
});

// Con opciones
watch(responsable, cargarTareas, { immediate: true });   // se ejecuta también al inicio
watch(filtros, guardar, { deep: true });                  // observa cambios anidados

watchEffect ejecuta la función inmediatamente y descubre solas sus dependencias, como un computed pero para efectos:

import { watchEffect } from 'vue';

watchEffect(() => {
  document.title = `Nómada Tareas (${pendientes.value})`;   // depende de `pendientes`
});

Y ambos aceptan una limpieza, que es otra vez tu destruir():

watchEffect((alLimpiar) => {
  const controlador = new AbortController();

  listarTareas({ responsable: responsable.value, signal: controlador.signal })
    .then((datos) => { tareas.value = datos; })
    .catch((error) => { if (error.name !== 'AbortError') fallo.value = error; });

  alLimpiar(() => controlador.abort());   // se ejecuta antes de la siguiente y al desmontar
});

Ese bloque resuelve la condición de carrera de 10-02 con el mismo AbortController de 07-03, y en Vue no hay que declarar [responsable] en ningún array: el watchEffect se apuntó como dependiente al leer responsable.value.

  1. Cuándo usar computed, watch o watchEffect

La tabla que evita el 90 % de los errores de reactividad en Vue:

Necesitas… Usa Por qué
Un valor derivado de otros computed Cacheado, perezoso, sin efectos
Reaccionar a un cambio concreto sabiendo el valor anterior watch Da nuevo y anterior, y es explícito
Sincronizar con algo externo usando varias fuentes watchEffect Detecta las dependencias solo
Ejecutar algo solo al montar onMounted Es un momento del ciclo, no un cambio
Guardar un valor derivado en un ref con watch Nada: usa computed Es el antipatrón equivalente al de 10-02

Ese último caso merece verse escrito, porque es exactamente el error del apartado 16 de 10-02 traducido a Vue:

// ❌ MAL: un watch para derivar
const horasAbiertas = ref(0);
watch(tareas, (t) => {
  horasAbiertas.value = t.filter((x) => x.estado !== 'hecha')
                         .reduce((s, x) => s + x.horasEstimadas, 0);
}, { immediate: true });

// ✅ BIEN
const horasAbiertas = computed(() =>
  tareas.value.filter((t) => t.estado !== 'hecha')
              .reduce((s, t) => s + t.horasEstimadas, 0)
);

Los mismos tres defectos: dos fuentes de verdad, un momento en que el valor está desfasado, y más código. La regla es transferible entre frameworks: si es un valor, decláralo como derivado; si es una acción, hazla en un efecto.

  1. El ciclo de vida: onMounted y onUnmounted

import { onMounted, onUnmounted, onUpdated, ref } from 'vue';

const contenedor = ref(null);      // un ref de plantilla: apuntará al nodo real

onMounted(() => {
  // El DOM ya existe: aquí se puede medir, enfocar, observar
  const observador = new IntersectionObserver(alEntrar);      // 07-06
  observador.observe(contenedor.value);

  onUnmounted(() => observador.disconnect());                  // ← la limpieza
});
<template>
  <ul ref="contenedor" class="lista-tareas">…</ul>
</template>

Los momentos principales, con su equivalencia:

Vue React Tu Nómada Tareas
onMounted useEffect(…, []) El constructor de TableroVista + primer render()
onUpdated Ejecución del componente actualizar()
onUnmounted return de useEffect destruir() (09-03)
onErrorCaptured Límite de error El try/catch de 02-05 en el controlador

La tercera fila vuelve a ser la importante, y el mensaje de 10-01 sigue vigente: Vue te da el momento garantizado, no adivina qué apagar. Un setInterval sin clearInterval en onUnmounted es exactamente la misma fuga que en JavaScript puro.

Detalle práctico: un ref de plantilla (ref="contenedor" en el HTML más const contenedor = ref(null) en el script) es el equivalente de useRef de React y de tu $('.lista-tareas') de siempre. Vale null hasta que el componente se monta, así que solo se puede usar dentro de onMounted o después.

  1. Comunicación: props y emits

El flujo es el mismo que en React —datos hacia abajo, eventos hacia arriba— con una diferencia: en Vue se declara explícitamente en las dos direcciones.

<script setup>
// Hacia abajo: props, con tipo, obligatoriedad y validación
const props = defineProps({
  tarea: {
    type: Object,
    required: true,
    validator: (t) => typeof t.id === 'number' && typeof t.titulo === 'string'
  },
  compacta: { type: Boolean, default: false }
});

// Hacia arriba: eventos declarados, con validación opcional
const emit = defineEmits({
  avanzar: (id) => typeof id === 'number',
  eliminar: (id) => typeof id === 'number'
});

function alPulsarAvanzar() {
  emit('avanzar', props.tarea.id);
}
</script>
<!-- El padre escucha con @ -->
<TarjetaTarea :tarea="tarea" @avanzar="avanzar" @eliminar="eliminar" />

Diferencias con React que merecen señalarse:

React Vue
Datos hacia abajo Props (sin declarar) defineProps con tipo y validador
Eventos hacia arriba Props de función (alAvanzar) defineEmits + emit('avanzar')
Validación en desarrollo Solo con TypeScript Incluida en JavaScript
Distinción datos/eventos Ninguna: todo son props Explícita en el API

La declaración de emits es valiosa por una razón poco obvia: documenta el contrato completo del componente. Leyendo defineProps y defineEmits sabes todo lo que entra y todo lo que sale, sin buscar por el fichero qué funciones se llaman. En React esa información está repartida por la firma y hay que reconstruirla.

Y la misma regla que en React: las props son de solo lectura. Modificar props.tarea.estado desde el hijo es un error, y Vue avisa en desarrollo. El hijo emite; el padre decide.

  1. slots: composición de contenido

Los slots son el equivalente de children de React, y son más expresivos porque admiten varios huecos con nombre.

<!-- src/componentes/Panel.vue -->
<template>
  <section class="panel">
    <header class="panel__cabecera">
      <slot name="cabecera">
        <h2>Sin título</h2>            <!-- contenido por defecto si no se aporta -->
      </slot>
    </header>

    <div class="panel__cuerpo">
      <slot />                          <!-- el slot por defecto -->
    </div>

    <footer v-if="$slots.pie" class="panel__pie">
      <slot name="pie" />               <!-- solo se pinta si el padre aportó algo -->
    </footer>
  </section>
</template>
<Panel>
  <template #cabecera>
    <h2>Tareas abiertas · {{ horasAbiertas }} h</h2>
  </template>

  <ListaTareas :tareas="visibles" @avanzar="avanzar" />

  <template #pie>
    <button @click="limpiarFiltros">Limpiar filtros</button>
  </template>
</Panel>

Y los slots con ámbito, que permiten al hijo pasar datos al contenido que aporta el padre:

<!-- ListaTareas.vue: la lista controla el bucle, el padre decide cómo se ve cada fila -->
<template>
  <ul class="lista-tareas">
    <li v-for="tarea in tareas" :key="tarea.id">
      <slot name="fila" :tarea="tarea" :vencida="estaVencida(tarea)">
        {{ tarea.titulo }}
      </slot>
    </li>
  </ul>
</template>
<ListaTareas :tareas="visibles">
  <template #fila="{ tarea, vencida }">
    <strong :class="{ rojo: vencida }">{{ tarea.titulo }}</strong>
    <em>{{ tarea.responsable }}</em>
  </template>
</ListaTareas>

Es un patrón muy potente: el componente aporta la lógica (el bucle, las claves, el filtrado) y quien lo usa aporta la presentación. En React el equivalente se hace pasando una función como prop, y funciona igual de bien; la diferencia es de sintaxis, no de capacidad.

  1. provide e inject

El equivalente de Context de React: poner un valor a disposición de todo el subárbol.

// En un ancestro
import { provide, ref, readonly } from 'vue';

const tema = ref('claro');
provide('tema', readonly(tema));                       // solo lectura para los descendientes
provide('alternarTema', () => { tema.value = tema.value === 'claro' ? 'oscuro' : 'claro'; });
// En cualquier descendiente, a cualquier profundidad
import { inject } from 'vue';

const tema = inject('tema', 'claro');                  // con valor por defecto
const alternarTema = inject('alternarTema');

Y una diferencia técnica con Context que importa: como Vue tiene reactividad de grano fino, proveer un ref no repinta a todos los que hacen inject. Solo se actualizan los que realmente leen su .value. El problema de rendimiento de Context que describía 10-03 —todos los consumidores se repintan cuando cambia el valor— sencillamente no existe aquí.

readonly es una buena práctica: los descendientes leen el valor y para cambiarlo llaman a la función provista. Así el control del estado se queda donde debe.

  1. Composables: usarTablero y usarFiltroResponsable

Un composable es una función que usa el API de reactividad de Vue y devuelve estado y operaciones. Es el equivalente exacto de los hooks personalizados de 10-02, con una diferencia importante: no tiene reglas de orden de llamada, porque Vue no identifica el estado por posición sino por objeto.

// src/composables/usarTablero.js
import { ref, computed } from 'vue';
import { SIGUIENTE, PESOS } from '../dominio/reglas.js';

/** Estado del tablero de Nómada Tareas y sus operaciones. */
export function usarTablero(tareasIniciales) {
  const tareas = ref(tareasIniciales);

  function avanzar(id) {
    const tarea = tareas.value.find((t) => t.id === id);
    if (!tarea) return;
    const destino = SIGUIENTE[tarea.estado];
    if (destino === null) return;          // R6: desde 'hecha' no se avanza
    tarea.estado = destino;                 // mutación directa: es reactivo
  }

  const resumen = computed(() => {
    const abiertas = tareas.value.filter((t) => t.estado !== 'hecha');
    return {
      total: tareas.value.length,
      horasTotales: tareas.value.reduce((s, t) => s + t.horasEstimadas, 0),
      horasAbiertas: abiertas.reduce((s, t) => s + t.horasEstimadas, 0),
      esfuerzo: tareas.value.reduce((s, t) => s + t.horasEstimadas * PESOS[t.prioridad], 0)
    };
  });

  return { tareas, avanzar, resumen };
}
// src/composables/usarFiltroResponsable.js
import { ref, computed } from 'vue';

export function usarFiltroResponsable(tareas) {
  const responsable = ref(null);

  const responsables = computed(() =>
    [...new Set(tareas.value.map((t) => t.responsable).filter(Boolean))].sort()
  );

  const visibles = computed(() =>
    responsable.value === null
      ? tareas.value
      : tareas.value.filter((t) => t.responsable === responsable.value)
  );

  return { responsable, responsables, visibles };
}

Compara avanzar con su equivalente de React en 10-02:

// React: inmutable, obligatoriamente
setTareas((actuales) => actuales.map((t) =>
  t.id === id && SIGUIENTE[t.estado] === destino ? { ...t, estado: destino } : t
));

// Vue: mutación directa
tarea.estado = destino;

Esta es la diferencia práctica más visible entre los dos frameworks. En React la inmutabilidad es obligatoria porque la detección es por referencia; en Vue el proxy detecta la escritura en la propiedad, así que mutar es correcto y natural. Ninguna de las dos es mejor: React gana en previsibilidad —el estado nunca cambia bajo tus pies y las comparaciones son triviales— y Vue gana en concisión y en el hecho de que las 599 tareas que no cambian ni siquiera se tocan.

Una convención sobre nombres: el ecosistema Vue usa useAlgo en inglés, igual que React. Aquí se escriben usarTablero y usarFiltroResponsable por coherencia con el resto del curso, pero en código real verás useTablero. Lo que importa es que sea consistente.

  1. Estado global con Pinia

Pinia es el almacén oficial de Vue, y su versión mínima es sorprendentemente parecida a un composable:

// src/almacenes/tablero.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { SIGUIENTE } from '../dominio/reglas.js';
import { BACKLOG } from '../datos/backlog.js';

export const usarAlmacenTablero = defineStore('tablero', () => {
  // ── estado ──────────────────────────────
  const tareas = ref(BACKLOG);
  const responsable = ref(null);

  // ── derivados (equivalen a los "getters") ──
  const visibles = computed(() =>
    responsable.value === null
      ? tareas.value
      : tareas.value.filter((t) => t.responsable === responsable.value)
  );

  const horasAbiertas = computed(() =>
    visibles.value.filter((t) => t.estado !== 'hecha')
                  .reduce((s, t) => s + t.horasEstimadas, 0)
  );

  // ── acciones ────────────────────────────
  function filtrarPor(nombre) { responsable.value = nombre; }

  function avanzar(id) {
    const tarea = tareas.value.find((t) => t.id === id);
    if (tarea && SIGUIENTE[tarea.estado] !== null) tarea.estado = SIGUIENTE[tarea.estado];
  }

  return { tareas, responsable, visibles, horasAbiertas, filtrarPor, avanzar };
});
<script setup>
import { storeToRefs } from 'pinia';
import { usarAlmacenTablero } from '../almacenes/tablero.js';

const almacen = usarAlmacenTablero();
const { visibles, horasAbiertas } = storeToRefs(almacen);   // ← storeToRefs, no desestructurar
const { avanzar } = almacen;                                 // las funciones sí se desestructuran
</script>

storeToRefs es imprescindible y es una aplicación directa del límite 1 del apartado 15: desestructurar el almacén copiaría los valores y rompería la reactividad. storeToRefs convierte el estado y los derivados en refs vinculados; las acciones son funciones normales y se pueden desestructurar sin problema.

Comparado con Redux (10-03):

Redux Toolkit Pinia
Conceptos Acciones, reductores, selectores, middleware Estado, derivados, acciones
Inmutabilidad Obligatoria (con Immer para escribirla cómoda) No hace falta: se muta
Trazabilidad y viaje en el tiempo Excelente Buena (DevTools de Vue)
Peso aproximado ~13 kB ~1,5 kB
Oficial del framework No
Un almacén o varios Uno, dividido en slices Varios independientes

La conclusión de 10-03 se mantiene: empieza sin almacén. Un composable con un ref en un módulo ya es estado compartido —porque un ref de módulo es un objeto único—, y a menudo basta. Pinia añade DevTools, sustitución en caliente, la garantía de una única instancia y soporte para renderizado en servidor. La diferencia con Redux es que el escalón es mucho más bajo: pasar de un composable a un almacén Pinia son diez minutos.

  1. El ecosistema oficial y la fragmentación

Pieza Vue (oficial) React (a elegir)
Enrutado Vue Router React Router, TanStack Router, el del meta-framework
Estado global Pinia Redux Toolkit, Zustand, Jotai, Context
Empaquetado Vite (creado por el mismo autor) Vite, o el del meta-framework
Pruebas de componentes Vue Test Utils + Vitest Testing Library + Jest/Vitest
Renderizado en servidor Nuxt Next.js, Remix, otros
Herramientas de desarrollo Vue DevTools React DevTools
Lenguaje JavaScript o TypeScript JavaScript o TypeScript

Esa columna de la izquierda explica por qué se dice que Vue tiene menos fragmentación, y merece un análisis honesto en las dos direcciones.

Ventajas reales: hay una respuesta canónica a cada pregunta; la documentación oficial cubre el conjunto y los ejemplos encajan entre sí; las actualizaciones están coordinadas; dos proyectos Vue de empresas distintas se parecen bastante, con lo que incorporarse a uno es más rápido; y no hay que dedicar una semana a evaluar cuatro librerías de enrutado.

Desventajas reales: menos innovación desde fuera, porque hay menos incentivo para competir con la solución oficial; menos opciones cuando tu caso es raro; y un ecosistema de terceros más pequeño en números absolutos —para una necesidad muy específica es más probable encontrar una librería React que una Vue—.

Es el eje librería-framework de 10-01 con Vue situado en el medio: más opiniones que React, menos que Angular. Y también aquí conviene desconfiar de las conclusiones automáticas: la fragmentación de React es un coste para un equipo pequeño y una ventaja para uno que necesita algo poco común.

  1. Nómada Tareas en Vue: la lista completa

La pantalla objetivo del módulo, tercera versión. El dominio es el mismo fichero JavaScript de 10-02 y 10-03, sin tocar:

// src/dominio/reglas.js — idéntico en las tres versiones
export const SIGUIENTE = Object.freeze({ pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null });
export const ETIQUETA = Object.freeze({ pendiente: 'Empezar', 'en-curso': 'Marcar hecha', hecha: 'Hecha' });
export const PESOS = Object.freeze({ alta: 3, media: 2, baja: 1 });
export const HOY = '2026-09-20';
export const estaVencida = (t, hoy = HOY) => t.estado !== 'hecha' && t.fechaLimite < hoy;

El filtro:

<!-- src/componentes/FiltroResponsable.vue -->
<script setup>
defineProps({
  responsables: { type: Array, required: true },
  modelValue: { type: String, default: null }
});
defineEmits(['update:modelValue']);
</script>

<template>
  <label class="filtro" for="filtro-responsable">
    Responsable:
    <select
      id="filtro-responsable"
      :value="modelValue ?? ''"
      @change="$emit('update:modelValue', $event.target.value || null)"
    >
      <option value="">Todos</option>
      <option v-for="nombre in responsables" :key="nombre" :value="nombre">
        {{ nombre }}
      </option>
    </select>
  </label>
</template>

La tarjeta es la del apartado 4. Y la lista:

<!-- src/componentes/ListaTareas.vue -->
<script setup>
import TarjetaTarea from './TarjetaTarea.vue';

defineProps({ tareas: { type: Array, required: true } });
defineEmits(['avanzar']);
</script>

<template>
  <p v-if="tareas.length === 0" class="lista-vacia">
    Ninguna tarea coincide con el filtro.
  </p>

  <ul v-else class="lista-tareas">
    <TarjetaTarea
      v-for="tarea in tareas"
      :key="tarea.id"
      :tarea="tarea"
      @avanzar="$emit('avanzar', $event)"
    />
  </ul>
</template>

<style scoped>
.lista-tareas { list-style: none; padding: 0; display: grid; gap: 0.5rem; }
.lista-vacia { color: var(--gris); font-style: italic; }
</style>

Y la aplicación:

<!-- src/App.vue -->
<script setup>
import { usarTablero } from './composables/usarTablero.js';
import { usarFiltroResponsable } from './composables/usarFiltroResponsable.js';
import FiltroResponsable from './componentes/FiltroResponsable.vue';
import ListaTareas from './componentes/ListaTareas.vue';
import { BACKLOG } from './datos/backlog.js';
import { computed } from 'vue';

const { tareas, avanzar, resumen } = usarTablero(BACKLOG);
const { responsable, responsables, visibles } = usarFiltroResponsable(tareas);

const horasVisibles = computed(() =>
  visibles.value.filter((t) => t.estado !== 'hecha')
                .reduce((s, t) => s + t.horasEstimadas, 0)
);
</script>

<template>
  <main class="tablero">
    <header class="tablero__cabecera">
      <h1>Nómada Tareas</h1>
      <FiltroResponsable v-model="responsable" :responsables="responsables" />
      <p class="tablero__resumen">
        {{ visibles.length }} de {{ resumen.total }} tareas · {{ horasVisibles }} h abiertas
      </p>
    </header>

    <ListaTareas :tareas="visibles" @avanzar="avanzar" />
  </main>
</template>

<style scoped>
.tablero__cabecera { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; }
.tablero__resumen { color: var(--gris); margin-left: auto; }
</style>

Los números canónicos, verificados: sin filtro, «6 de 6 tareas · 45 h abiertas». Con «Iván», «3 de 6 · 25 h». Con «Lucía», «1 de 6 · 14 h». Con «Marta», «2 de 6 · 6 h». Y al pulsar «Empezar» en la tarea 6, avanzar muta tarea.estado, el proxy avisa a los computed que leyeron esa propiedad, y se actualizan solo la tarjeta afectada y el resumen. Las otras cinco tarjetas no se vuelven a ejecutar siquiera.

  1. Comparación con React y con JavaScript puro

La tabla de las tres versiones de la misma pantalla:

Concepto JavaScript puro React Vue
Unidad Módulo vista/*.js + <template> + CSS Componente de función .jsx Componente de un solo fichero .vue
Estilos aislados No (clases globales) No por defecto (scoped)
Estado Objeto estado + render() manual useState ref
Derivados Caché por versión escrita a mano useMemo (opcional, para optimizar) computed (la forma natural)
Actualizar render() a mano Reejecutar el componente Reejecutar solo lo que lee el dato
Inmutabilidad Recomendada Obligatoria No hace falta: se muta
Identidad en listas data-id + reconciliar() key :key
Limpieza destruir() invocado a mano return de useEffect onUnmounted
Dependencias de efectos Array declarado a mano Detectadas automáticamente
Eventos hacia arriba CustomEvent Prop de función emit declarado
Composición de contenido <template> + <slot> de web components children slots con nombre y ámbito
Lógica reutilizable Módulos y clases Hooks (con reglas de orden) Composables (sin reglas de orden)

Y las métricas de la misma pantalla:

Métrica JavaScript puro React Vue
Líneas de la vista ~210 ~130 ~120
Líneas de infraestructura propia ~60 0 0
Ficheros de la vista 5 (dom, tarjeta, tablero-vista, controlador, <template>) 4 .jsx + 2 hooks 4 .vue + 2 composables
Dependencias de producción 0 2 1
Peso del motor (comprimido, aprox.) 0 ~45 kB ~35 kB
Ficheros a tocar para añadir un dato a la tarjeta 2 1 1 (incluido su estilo)
Trabajo al cambiar el filtro Reconciliar la lista Ejecutar 6 componentes + comparar Actualizar solo lo que cambió

Y el balance cualitativo, sin declarar ganadores:

Vue gana en: concisión (menos ceremonia para lo mismo), estilos aislados sin convenciones de nombres, reactividad automática que elimina el array de dependencias, computed como forma natural en lugar de optimización, un ecosistema oficial coherente y una curva de aprendizaje más suave para quien viene de HTML y CSS.

React gana en: tamaño del mercado laboral y del ecosistema de terceros, un modelo mental más simple de explicar (es una función que se reejecuta), «todo es JavaScript» sin sintaxis de plantilla que aprender, mejor soporte de herramientas de terceros, y React Native para móvil nativo.

JavaScript puro gana en: cero dependencias, cero peso de motor, cero compilación obligatoria, control absoluto y un código que seguirá funcionando dentro de diez años sin actualizar nada.

Las contrapartidas de Vue, dichas con claridad: la reactividad automática falla en silencio cuando se pierde (apartado 15), y el fallo es «no se actualiza», que es más difícil de diagnosticar que un error; la sintaxis de plantilla es un lenguaje adicional que hay que aprender y que las herramientas genéricas no entienden sin complementos; y el mercado laboral es más pequeño que el de React en la mayoría de sitios, lo cual es un criterio legítimo aunque no sea técnico.

Errores Comunes y Consejos

Olvidar .value en el script. El error número uno. En la plantilla se desenvuelve solo, en el script no. if (responsable === 'Iván') compara un objeto ref con una cadena y siempre es falso, sin error. La regla de ESLint del complemento oficial de Vue lo detecta: actívala.

Desestructurar un objeto reactivo o un almacén de Pinia. const { responsable } = filtros copia el valor y rompe el vínculo. Usa toRefs para objetos reactivos y storeToRefs para almacenes.

Leer valores reactivos fuera del contexto de seguimiento. Dentro de un setTimeout, un .then() o un manejador registrado a mano, la lectura no se registra. Lee el valor antes, en el cuerpo del efecto.

Usar watch para derivar valores. Es el mismo antipatrón que useEffect para derivar estado en React: dos fuentes de verdad y un instante de desfase. Si es un valor, es computed.

Confundir v-if con v-show. v-show deja los nodos en el DOM: con 600 tareas, 600 nodos ocultos, encontrables con Ctrl+F, con el coste de memoria y de árbol de accesibilidad que mediste en 09-04. Para filtrar, v-if.

Usar el índice como :key. El mismo error que en React y que en tu reconciliar. Si la lista se filtra o se reordena, los nodos cambian de dato y se pierden el foco y el estado interno.

Olvidar .prevent en los formularios. @submit sin .prevent recarga la página. Es tan frecuente que Vue hizo el modificador precisamente por eso.

Olvidar .number en los campos numéricos. <input type="number" v-model="horas"> guarda una cadena. La validación de la R3 (horas > 0 && horas <= 40) compararía texto. Usa v-model.number.

Mutar props desde el hijo. Vue avisa en desarrollo. El hijo emite un evento; el padre decide qué hacer. Es el flujo unidireccional, que v-model no rompe sino que formaliza.

Creer que v-model es magia bidireccional. Es un v-bind más un v-on. Entenderlo así evita esperar comportamientos que no existen.

Consejo: usa ref por defecto y reactive solo cuando lo justifiques. ref funciona con cualquier tipo, se puede reemplazar entero, y el .value hace visible dónde está la reactividad.

Consejo: mantén el dominio fuera de los componentes. dominio/reglas.js es el mismo fichero en las tres versiones de esta pantalla, y esa es la mejor prueba de que la disciplina de 10-01 funciona: al cambiar de framework solo se reescribe la vista.

Consejo: instala Vue DevTools desde el primer día. Permite inspeccionar el árbol de componentes, ver el valor de cada ref y cada computed en tiempo real, y —muy útil— ver qué dependencias tiene registradas cada efecto, que es la forma directa de diagnosticar una reactividad perdida.

Ejercicios

Ejercicio 1 · Cazar las reactividades perdidas

Este componente tiene cuatro fallos de reactividad. Encuéntralos, explica por qué falla cada uno y escribe la versión corregida.

<script setup>
import { ref, reactive, computed, watch } from 'vue';
import { BACKLOG } from '../datos/backlog.js';

const filtros = reactive({ responsable: null, texto: '' });
const { responsable } = filtros;

const tareas = ref(BACKLOG);

const horasAbiertas = ref(0);
watch(tareas, (t) => {
  horasAbiertas.value = t.filter((x) => x.estado !== 'hecha')
                         .reduce((s, x) => s + x.horasEstimadas, 0);
}, { immediate: true });

const visibles = computed(() => {
  setTimeout(() => console.log(filtros.texto), 0);
  return responsable === null
    ? tareas.value
    : tareas.value.filter((t) => t.responsable === responsable);
});

function limpiar() {
  filtros = { responsable: null, texto: '' };
}
</script>

<template>
  <p>{{ visibles.length }} tareas · {{ horasAbiertas }} h</p>
  <button @click="limpiar">Limpiar</button>
</template>

Ejercicio 2 · Un composable con ciclo de vida

Escribe un composable usarTareasRemotas(responsable) que:

  1. Reciba un ref con el responsable filtrado.
  2. Cargue las tareas del servidor con tu listarTareas de 07-02 cada vez que cambie el responsable.
  3. Exponga tareas, cargando y error como refs.
  4. Cancele la petición anterior al lanzar una nueva y al desmontar el componente, evitando la condición de carrera de 10-02.
  5. Exponga también un computed con las horas abiertas de las tareas cargadas.

Después, explica por qué esta versión no necesita ningún array de dependencias y qué error de React se evita con ello.

Ejercicio 3 · La misma pantalla, tres veces

Sin escribir código nuevo, rellena esta tabla comparando el mismo cambio funcional en las tres versiones de la pantalla que has visto en el módulo: añadir un badge que muestre el esfuerzo ponderado de cada tarea (horasEstimadas × PESOS[prioridad]), con fondo rojo si supera 30.

Paso JavaScript puro React Vue
Ficheros que hay que abrir
Dónde va el cálculo
Dónde va el marcado
Dónde va el estilo
Riesgo de olvidar algo
Qué se recalcula al cambiar el filtro

Después responde: en la versión de JavaScript puro, ¿qué pasaría si añadieras el badge a pintarTarjeta pero olvidaras añadirlo también al <template> de index.html?

Soluciones

Solución 1

Fallo 1 · const { responsable } = filtros (línea 6). Desestructurar un objeto reactive copia el valor primitivo null y pierde el vínculo. responsable valdrá null para siempre, así que visibles nunca filtrará nada aunque el usuario cambie el <select>. Es el límite 1 del apartado 15.

Fallo 2 · watch para derivar horasAbiertas. Es el antipatrón del apartado 18: dos fuentes de verdad, un instante de desfase y más código. Además, como tareas es un ref a un array y el watch no es profundo, mutar el estado de una tarea no dispara el watch: horasAbiertas se quedaría obsoleta al marcar una tarea como hecha, que es justamente el caso de uso principal.

Fallo 3 · Lectura dentro de un setTimeout en el computed. La lectura de filtros.texto ocurre después de que el computed haya terminado, fuera del contexto de seguimiento: no se registra como dependencia. Además, un computed no debe tener efectos secundarios: debe ser una función pura de sus dependencias, igual que tus reductores de 10-03.

Fallo 4 · filtros = { … } en limpiar. Reasignar un objeto reactive rompe el vínculo con la plantilla, que sigue apuntando al proxy original. Además, filtros está declarado con const, así que esto lanzaría un TypeError en tiempo de ejecución (01-05).

Versión corregida:

<script setup>
import { ref, computed } from 'vue';
import { BACKLOG } from '../datos/backlog.js';

// ref por defecto: funciona con cualquier tipo y se puede reemplazar entero
const filtros = ref({ responsable: null, texto: '' });
const tareas = ref(BACKLOG);

// Derivado: computed, no watch
const horasAbiertas = computed(() =>
  tareas.value.filter((t) => t.estado !== 'hecha')
              .reduce((s, t) => s + t.horasEstimadas, 0)
);

// Puro y con todas las lecturas dentro del contexto de seguimiento
const visibles = computed(() => {
  const { responsable, texto } = filtros.value;      // lectura DENTRO: sí se registra
  const t = texto.trim().toLowerCase();
  return tareas.value
    .filter((x) => responsable === null || x.responsable === responsable)
    .filter((x) => t === '' || x.titulo.toLowerCase().includes(t));
});

function limpiar() {
  filtros.value = { responsable: null, texto: '' };   // reemplazo del contenido del ref
}
</script>

<template>
  <p>{{ visibles.length }} tareas · {{ horasAbiertas }} h</p>
  <button @click="limpiar">Limpiar</button>
</template>

Nota sobre la desestructuración de la línea const { responsable, texto } = filtros.value: aquí sí es correcta, porque ocurre dentro del computed. La lectura de filtros.value registra la dependencia en ese momento; lo que se desestructura después son valores ya leídos, usados inmediatamente. La regla no es «nunca desestructures», sino «no guardes valores desestructurados fuera de un contexto reactivo».

Solución 2

// src/composables/usarTareasRemotas.js
import { ref, computed, watchEffect } from 'vue';
import { listarTareas } from '../datos/api-tareas.js';   // tu módulo de 07-02

/**
 * Carga las tareas del servidor filtradas por responsable.
 * @param {import('vue').Ref<string|null>} responsable
 */
export function usarTareasRemotas(responsable) {
  const tareas = ref([]);
  const cargando = ref(false);
  const error = ref(null);

  watchEffect(async (alLimpiar) => {
    const controlador = new AbortController();
    alLimpiar(() => controlador.abort());     // ← antes de la siguiente ejecución y al desmontar

    cargando.value = true;
    error.value = null;

    try {
      // La lectura de responsable.value registra la dependencia AQUÍ, antes del await
      tareas.value = await listarTareas({
        responsable: responsable.value,
        signal: controlador.signal
      });
    } catch (fallo) {
      if (fallo.name === 'AbortError') return;   // cancelación esperada: no es un error
      error.value = fallo;                        // ErrorDeApi de 07-03
    } finally {
      cargando.value = false;
    }
  });

  const horasAbiertas = computed(() =>
    tareas.value.filter((t) => t.estado !== 'hecha')
                .reduce((s, t) => s + t.horasEstimadas, 0)
  );

  return { tareas, cargando, error, horasAbiertas };
}

Uso:

<script setup>
import { ref } from 'vue';
import { usarTareasRemotas } from './composables/usarTareasRemotas.js';

const responsable = ref(null);
const { tareas, cargando, error, horasAbiertas } = usarTareasRemotas(responsable);
</script>

<template>
  <p v-if="cargando" role="status">Cargando tareas…</p>
  <p v-else-if="error" role="alert">No se han podido cargar: {{ error.message }}</p>
  <ul v-else :aria-busy="cargando">
    <li v-for="t in tareas" :key="t.id">{{ t.titulo }}</li>
  </ul>
  <p>{{ horasAbiertas }} h abiertas</p>
</template>

Por qué no hace falta array de dependencias. El watchEffect lee responsable.value durante su ejecución, y el proxy registra esa lectura automáticamente. Cuando el valor cambia, el efecto se vuelve a ejecutar — después de haber llamado a la limpieza, que aborta la petición en vuelo.

Qué error de React se evita. Dos, en realidad:

  1. La dependencia olvidada. En React, escribir }, []) en lugar de }, [responsable]) produce un componente que carga una vez y nunca se actualiza. Es tan frecuente que existe una regla de ESLint específica para ello. Aquí es imposible por construcción.
  2. La dependencia que cambia siempre. Si en React pasaras un objeto { responsable } como dependencia, se recrearía en cada renderizado y la petición se lanzaría infinitamente. En Vue las dependencias son los valores reactivos leídos, no referencias comparadas.

Y un aviso, para no vender humo: hay un caso donde el seguimiento automático de Vue también falla. Las lecturas que ocurren después de un await no se registran, porque el contexto de seguimiento se cierra al suspenderse la función. Por eso el código lee responsable.value antes del await, dentro de la llamada. Si necesitaras leer otro ref después del await, tendrías que leerlo antes y guardarlo. Es el mismo límite del apartado 15, en su forma más sutil.

Solución 3

Paso JavaScript puro React Vue
Ficheros que hay que abrir 3: index.html (<template>), js/vista/tarjeta.js, css/estilos.css 2: TarjetaTarea.jsx, el CSS 1: TarjetaTarea.vue
Dónde va el cálculo En pintarTarjeta, como variable local En el cuerpo del componente, calculado en el render En un computed del <script setup>
Dónde va el marcado Añadir un <span> al <template> y rellenarlo en pintarTarjeta En el JSX, junto al resto En el <template> del mismo fichero
Dónde va el estilo Clase nueva en el CSS global, con prefijo para evitar colisiones Clase en el CSS global, o módulo CSS En el <style scoped>, sin riesgo de colisión
Riesgo de olvidar algo Alto: tres ficheros y un acoplamiento por nombre de clase que nadie comprueba Bajo Muy bajo: todo está en la misma pantalla
Qué se recalcula al cambiar el filtro Se reconcilian las tarjetas visibles y se recalcula el badge de cada una Se ejecutan los 6 componentes; con memo, solo los que cambian Solo los computed cuyas dependencias cambiaron

Qué pasa si olvidas el <span> en el <template> de index.html. Depende de cómo esté escrito pintarTarjeta. Si usa $('.tarea__esfuerzo', li).textContent = …, el selector devuelve null y la línea lanza un TypeError: Cannot set properties of null en tiempo de ejecución, al pintar la primera tarjeta: la aplicación se rompe entera. Si en cambio comprueba antes (const badge = $('.tarea__esfuerzo', li); if (badge) …), no pasa nada visible: el badge simplemente no aparece, sin ningún error, y el fallo solo se detecta mirando la pantalla o con una prueba de extremo a extremo (08-06).

Ese es, con nombres y apellidos, el acoplamiento sin comprobar del que hablaba el apartado 13 de 10-01. En la versión Vue el error es imposible: el marcado y el código que lo rellena son el mismo fichero, y el compilador de plantillas avisa si una variable no existe.

Conclusión

Has visto Vue en su versión moderna —Composition API y <script setup>— y, sobre todo, has visto el segundo modelo de reactividad de 10-01 funcionando de verdad.

Sabes qué significa framework progresivo y lo has comprobado: los mismos conceptos sirven para mejorar una página existente con una etiqueta <script> y sin compilar, o para montar una aplicación completa con Vite, Vue Router y Pinia. Conoces los Componentes de un Solo Fichero con sus tres bloques y por qué agrupar plantilla, lógica y estilos no viola la separación de intereses sino que la respeta: la estructura, el aspecto y el comportamiento de una tarjeta son un solo interés, y el <style scoped> convierte el aislamiento en una garantía del compilador en lugar de una convención de nombres.

Dominas la sintaxis de plantilla: la interpolación con su escapado automático, v-bind con las formas de objeto para clases y estilos —cuatro instrucciones imperativas de classList reducidas a una descripción—, v-on con sus modificadores que codifican el preventDefault y el stopPropagation de 06-03, la diferencia entre v-if y v-show —crear y destruir frente a display: none, con los 7.812 nodos frente a 1.194 que mediste en 09-04—, v-for con :key, que por tercera vez en el módulo es tu data-id de 06-06, y v-model desmitificado: un v-bind que baja el valor y un v-on que lo sube, el formulario controlado de 10-02 escrito en una línea, con .trim y .number que evitan errores reales en las reglas R2 y R3.

Entiendes la reactividad por dentro. Sabes por qué existe .value —JavaScript no permite interceptar la asignación a una variable, solo a una propiedad— y que un ref es un objeto con get/set interceptados, exactamente el mecanismo de 05-03. Sabes cómo los proxies registran las dependencias durante la ejecución de un efecto, con sus tres consecuencias: detección automática y precisa, detección dinámica que ignora las ramas no tomadas, y detección solo durante la ejecución — de donde salen los cuatro límites donde la reactividad se pierde: desestructurar un objeto reactivo, desestructurar props, pasar un primitivo a una función y reemplazar un reactive entero, con toRefs y storeToRefs como herramientas. Y tienes la comparación honesta: React te obliga a declarar dependencias y por eso puede comprobarlas con ESLint; Vue las descubre solo y por eso el fallo es silencioso.

Sabes que computed es tu caché por versión de 09-02 con la invalidación descubierta automáticamente —veinte líneas y un riesgo de olvido convertidos en una línea sin riesgo—, y que a diferencia de useMemo no es una optimización sino la forma natural de escribir derivados. Distingues watch y watchEffect con el criterio para elegir entre los tres, y reconoces el antipatrón de derivar con watch como el mismo error que derivar con useEffect. Conoces el ciclo de vida con onMounted y onUnmounted, que vuelve a ser tu destruir() con la llamada garantizada pero el contenido a tu cargo.

Sabes comunicar componentes con props y emits declarados —un contrato completo y validado que se lee en dos líneas—, componer contenido con slots con nombre y con ámbito, e inyectar valores con provide/inject, que a diferencia de Context no repinta a todos los consumidores porque la reactividad es de grano fino. Extraes lógica en composables sin reglas de orden de llamada, y sabes que la diferencia práctica más visible con React está en avanzar: donde React exige un map inmutable, Vue muta la propiedad y el proxy hace el resto. Conoces Pinia en su versión mínima —casi idéntica a un composable, con storeToRefs como requisito— y el ecosistema oficial que explica la menor fragmentación de Vue, con sus ventajas y sus costes.

Y has visto la tercera versión de la misma pantalla, con el mismo dominio/reglas.js sin tocar en las tres, ~120 líneas, un solo fichero por componente con sus estilos aislados, y actualizaciones que tocan solo lo que ha cambiado. Con las contrapartidas dichas: la reactividad perdida falla en silencio, la sintaxis de plantilla es un lenguaje más, y el mercado laboral es más pequeño.

Quedan dos modelos por ver. El siguiente es el más distinto de todos: un framework que no es una librería ni un framework progresivo sino una plataforma completa, con TypeScript como requisito de hecho, inyección de dependencias, una CLI que genera todo, observables para los flujos asíncronos, y —desde hace poco— señales muy parecidas a los ref que acabas de aprender. Es el extremo «con opiniones» del eje de 10-01: Conceptos Básicos de Angular.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados