Cómo funciona internamente el sistema de reactividad en Vue 3
Vue 3 introdujo un sistema de reactividad completamente rediseñado respecto a Vue 2, basado en Proxy y en un grafo dinámico de dependencias. Aunque la API pública (ref, reactive, computed, watch) resulta simple, su funcionamiento interno responde a un modelo cuidadosamente optimizado para granularidad, rendimiento y previsibilidad. Entender este modelo no es opcional en aplicaciones grandes: es la diferencia entre depurar síntomas y resolver causas.
Introducción basada en un problema real
En equipos que ya utilizan Vue 3 en producción, las dudas más costosas no suelen ser “cómo usar ref”, sino estas:
- ¿Cómo sabe Vue exactamente qué actualizar y qué no tocar?
- ¿Por qué un cambio parece correcto, pero no re-renderiza?
- ¿Por qué una pantalla vuelve a renderizar demasiado y se vuelve lenta?
La respuesta no está en una API aislada. Está en el motor interno: cómo Vue registra dependencias al leer estado y cómo decide qué efectos ejecutar al escribir estado.
Cuando este modelo mental no está claro, se depura a ciegas. Cuando sí lo está, se puede razonar sobre causa raíz, costo de render y comportamiento de computed o watchEffect con precisión.
Modelo mental simplificado
Dependency tracking en una frase
Cada vez que un efecto activo (render, computed, watchEffect) lee una propiedad reactiva, Vue registra una relación:
efecto X depende de target.keyLuego, si target.key cambia, Vue solo notifica los efectos suscritos a esa clave.
Estructura interna: WeakMap → Map → Set
Un modelo simplificado del almacenamiento de dependencias:
WeakMap: índice por objeto reactivo (target).Map: índice por propiedad dentro de ese objeto (key).Set: efectos que dependen de esa propiedad.
bucket (WeakMap)
-> targetA (Map)
-> "count" (Set: effectRender, effectComputed)
-> "ok" (Set: effectRender)
-> targetB (Map)
-> "name" (Set: effectProfile)Esta estructura permite:
- Evitar memory leaks (gracias a
WeakMap). - Tener granularidad por propiedad.
- Ejecutar únicamente los efectos necesarios.
activeEffect: la pieza crítica
Vue mantiene una referencia global temporal al efecto que está ejecutándose: activeEffect.
Flujo:
- Empieza
effect(fn)→activeEffect = fn. - Dentro de
fn, una lectura reactiva ejecutatrack(target, key). trackasociaactiveEffectalSetdetarget.key.- Al terminar
fn,activeEffectvuelve al anterior (stack de efectos anidados).
Sin activeEffect, no hay tracking.
Mini implementación desde cero en JavaScript puro
La implementación real es más compleja (maneja tipos de operación, colecciones como Map/Set, flags internos, etc.), pero este modelo replica las piezas esenciales:
track(target, key)trigger(target, key)effect(fn)
// Grafo global de dependencias.
const bucket = new WeakMap()
// Effect activo y pila para efectos anidados.
let activeEffect = null
const effectStack = []
function cleanup(effectFn) {
for (const deps of effectFn.deps) {
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
const result = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] ?? null
return result
}
effectFn.deps = []
effectFn.options = options
if (!options.lazy) {
effectFn()
}
return effectFn
}
function track(target, key) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
depsMap = new Map()
bucket.set(target, depsMap)
}
let deps = depsMap.get(key)
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
if (!deps.has(activeEffect)) {
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
if (!deps) return
const effectsToRun = new Set()
deps.forEach((effectFn) => {
if (effectFn !== activeEffect) effectsToRun.add(effectFn)
})
effectsToRun.forEach((effectFn) => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (!Object.is(oldValue, value)) {
trigger(target, key)
}
return result
}
})
}const state = reactive({ count: 0, ok: true })
effect(() => {
console.log("render ->", state.ok ? state.count : "hidden")
})
state.count++
state.ok = falsePaso a paso de lo que ocurre
effectejecuta la función de render y la marca como activa.- Al leer
state.okystate.count, elgetdelProxydisparatrack. trackguarda dependencias enWeakMap → Map → Set.- Cuando cambias
state.count, elsetdisparatrigger. triggerbusca elSetde esa key y re-ejecuta solo los efectos dependientes.
Este patrón es exactamente el núcleo del sistema de reactividad de Vue 3.
Conexión con Vue real
reactive usa Proxy
reactive no “magia” valores: crea un Proxy que intercepta operaciones (get, set, has, deleteProperty, iteración, etc.).
- En
getse hace tracking. - En
setse hace trigger. - Para arrays y colecciones (
Map,Set,WeakMap,WeakSet) existen manejadores específicos.
ref envuelve primitivos
Como un primitivo no puede proxificarse por propiedad, Vue usa un objeto contenedor con getter/setter en .value.
Simplificación conceptual:
function ref(raw) {
const box = {
get value() {
track(box, "value")
return raw
},
set value(newValue) {
if (!Object.is(raw, newValue)) {
raw = newValue
trigger(box, "value")
}
}
}
return box
}En Vue real:
- Los objetos pasados a
refse convierten internamente en reactivos. - En templates,
.valuese desempaqueta automáticamente (ref unwrapping).
computed se construye sobre effect
computed se implementa usando un effect lazy:
- No se evalúa hasta que alguien lee
.value. - Cachea el resultado.
- Se invalida cuando cambia una dependencia interna.
- Expone su propio
track/triggerpara quienes dependen del computed.
Internamente, combina:
- Un efecto con
lazy: true - Un flag
dirty - Un scheduler que marca como inválido en lugar de recalcular inmediatamente
Scheduler y batching
Vue evita trabajo redundante dentro del mismo tick:
- En lugar de ejecutar cada efecto inmediatamente, puede encolarlo.
- Deduplica jobs repetidos.
- Hace flush en una microtask.
Mini scheduler conceptual:
const queue = new Set()
let flushing = false
const p = Promise.resolve()
function scheduler(job) {
queue.add(job)
if (flushing) return
flushing = true
p.then(() => {
queue.forEach((j) => j())
queue.clear()
flushing = false
})
}Esto explica por qué 10 mutaciones síncronas pueden producir un único render final.
Errores comunes
La desestructuración rompe la reactividad
const state = reactive({ count: 0 })
const { count } = state // count deja de estar enlazadoCorrección:
- Usar
toRef(state, "count") - Acceder como
state.count.
Mutaciones fuera del proxy
Si mutas una referencia “raw” fuera del Proxy, Vue no puede disparar trigger.
Todo acceso/escritura que deba ser reactivo debe pasar por el proxy o por .value.
shallowRef vs ref
shallowRef solo rastrea el reemplazo de .value, no mutaciones profundas.
const user = shallowRef({ name: "Ana" })
user.value.name = "Eva" // no dispara por sí solo
user.value = { ...user.value, name: "Eva" } // sí disparaEs útil cuando:
- Manejas estructuras grandes,
- Trabajas con librerías externas,
- Quieres controlar manualmente cuándo invalidar.
Effects que se ejecutan más de lo esperado
Causas típicas:
- Leer demasiadas fuentes dentro de un mismo
watchEffect; - Crear objetos nuevos en cada evaluación de
computed; - Usar
watchEffectcuandocomputedera suficiente; - Mutar estado dentro de un efecto sin aislar dependencias.
Conclusión
Cuando entiendes el motor interno de Vue 3, cambia tu forma de construir UI:
- Pasas de ensayo-error a diagnóstico por dependencias;
- Distingues entre problema de tracking y problema de rendering;
- Diseñas estado para reducir renders y costo computacional.
En aplicaciones grandes, esto impacta directamente en:
- Rendimiento estable bajo carga;
- Menos bugs intermitentes de sincronización;
- Decisiones más finas sobre
ref,reactive,shallowRef,computed,watchy scheduling.
La reactividad de Vue 3 no es magia. Es un grafo de dependencias bien diseñado y un scheduler eficiente. Entenderlo te permite escribir código más predecible, más performante y mucho más fácil de depurar.
