How Vue Reactivity Actually Works Under the Hood (A Simple Explanation With Internals)

If you’ve been working with Vue for a while, you’ve probably learned the rules:

  • ref() holds a value
  • reactive() makes an object reactive
  • computed() derives state
  • changing state triggers a re-render

But none of that explains why it works.

Why does changing count.value make the component update?
Why does Vue know which component depends on which variable?
What exactly happens during render()?

To really understand Vue — and to write scalable, predictable code — you need to understand what’s happening under the hood.

Let’s open that black box.

And don’t worry — I’m going to explain this without computer-science jargon.

The simple idea behind all of Vue: “track” and “trigger”

The entire Vue reactivity system — every ref, reactive object, computed, watcher — is built on two operations:

track()   // remember that some code used this value
trigger() // notify all code that depends on this value

That’s it.

Every time you read a reactive value → Vue calls track().
Every time you write to a reactive value → Vue calls trigger().

Vue keeps a mapping:

reactiveValue → list of functions that should re-run when it changes

Those “functions” are typically the component’s render function, or a computed getter, or a watcher.

This is the entire reactivity system in one sentence:

Vue re-runs functions that used a reactive value when that value changes.

The rest is implementation detail.

Let’s build a tiny Vue-like reactivity system from scratch

Vue 3 uses Proxy under the hood to intercept reads and writes.

Here’s a tiny toy example that mirrors how Vue works:

let activeEffect: Function | null = null

const effects = new Map()

function track(key: string) {
  if (!activeEffect) return

  let deps = effects.get(key)
  if (!deps) {
    deps = new Set()
    effects.set(key, deps)
  }
  deps.add(activeEffect)
}

function trigger(key: string) {
  const deps = effects.get(key)
  if (!deps) return
  deps.forEach(fn => fn())
}

function reactive(obj: Record<string, any>) {
  return new Proxy(obj, {
    get(target, key) {
      track(key as string)
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(key as string)
      return true
    }
  })
}

Now watch the magic:

const state = reactive({ count: 0 })

function effect(fn: Function) {
  activeEffect = fn
  fn()
  activeEffect = null
}

effect(() => {
  console.log("count changed:", state.count)
})

state.count++
// → logs: "count changed: 1"

Congratulations — this is the core idea of Vue reactivity.

Vue’s real system is more advanced — optimized dependency tracking, cleanup, effect scopes, watcher flushing — but the foundations are exactly this.

How ref() works internally

A ref is just a reactive object with a .value property.

Inside Vue, the implementation is roughly:

class RefImpl {
  private _value: any
  private deps = new Set<Function>()

  constructor(value: any) {
    this._value = value
  }

  get value() {
    trackRefValue(this)
    return this._value
  }

  set value(newValue) {
    this._value = newValue
    triggerRefValue(this)
  }
}

This is why you must use .value — it’s where access tracking happens.

When you write:

const count = ref(0)
count.value++

Vue performs:

  • track: remember that some component/computed uses count.value
  • trigger: re-run those functions when the value changes

How reactive() works internally

Vue wraps your object in a Proxy. The proxy intercepts all get and set operations.

When you read a property:

state.user.name

Vue calls:

track(state.user, "name")

When you change one:

state.user.name = "Sarah"

Vue calls:

trigger(state.user, "name")

This lets Vue know exactly which fields of which objects your UI depends on.

The real magic: dependency tracking

Here’s a key insight junior developers often miss:

Vue tracks dependencies at runtime, not at compile time.

This means Vue knows exactly which reactive variables your component used while rendering.

Consider this component:

const count = ref(0)
const double = computed(() => count.value * 2)

When double is evaluated:

  • it reads count.value
  • so Vue records: “double depends on count”

Thus, when count.value changes → double re-runs automatically.

Vue builds a dependency graph dynamically, every time reactive values are used.

This is why:

  • computed values update automatically
  • watchers run when the reactive value they use changes
  • components re-render when data they accessed changes

Why components re-render

Inside a component’s render function, Vue does this:

  • evaluates the template → which reads reactive values
  • tracks each read → maps reactive values → render function
  • stores these dependencies

Later, if a variable changes:

  • Vue sees which render functions depend on it
  • schedules those components to update

So updating state.count will only re-render components that used state.count.

This is why Vue apps can stay performant even with lots of components.

Why some values don’t trigger updates

Vue tracks accesses, not assignments.

Consider this:

const state = reactive({
  items: []
})

state.items.push(1)

Push mutates an array without calling set on the array property (because the reference didn’t change).

But Vue patches array methods like push to call trigger() internally — otherwise arrays wouldn’t be reactive.

If you create a custom object with methods, Vue will not track them unless they use reactive data.

Why destructuring breaks reactivity

This confuses a lot of beginners:

const state = reactive({ count: 0 })
const { count } = state   // ❌ reactivity lost

Why?

Because you copy state.count into a standalone variable.
It no longer goes through the reactive proxy.

This explains why Pinia recommends:

const store = useStore()

// ❌ breaks reactivity:
const { count } = store

// ✔ keeps reactivity:
const count = store.count

Or:

const { count } = storeToRefs(store)

Why watchers run too often

Because a watcher reacts to any change of any reactive variable used inside it.

For example:

watch(() => state.user, () => {
  console.log("User changed")
})

This watcher triggers whenever any property of state.user changes.

Vue isn’t being “weird”.
It’s doing exactly what you told it:

“Tell me whenever this entire object changes.”

If you want precision:

watch(() => state.user.name, () => {
  console.log("Name changed")
})

Why your app re-renders too much

Every unintended use of reactive data inside a render or computed function adds a dependency.

Vue will track it.

Vue will re-run it.

Vue will re-render.

Even if it was an accident.

Final mental model (save this forever)

If you truly understand this sentence, you understand Vue:

Vue re-runs anything that used a reactive value when that value changes.

That’s the whole system.

Everything else — computed, watchers, refs, reactive objects — is just different UI around the same engine.

Once this clicks, Vue stops feeling magical and starts feeling predictable.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Product Marketing Summit | Chicago 2025

Related Posts