VuePublished 1/3/2024

ref vs. reactive: The Difference, the Internals, and When to Use Each

Break down how ref and reactive are actually implemented under the hood in Vue 3, understand why destructuring a reactive object kills its reactivity, and walk away with a practical framework for picking the right API for the job.

A Question Everyone Runs Into

When you start writing Vue 3's Composition API, the first thing that trips you up usually isn't the syntax — it's the question of whether a given variable should be wrapped in ref or in reactive.

Both APIs produce reactive data, both show up in the official docs, and at a glance either one seems to work. But mix them together in a real project for a while and you'll start hitting weird bugs: a destructured variable suddenly stops reacting, reassigning an object stops updating the UI... All of these point back to the same root cause — ref and reactive are implemented in fundamentally different ways under the hood, even though both get called "reactive."

Once you understand that difference, picking the right API stops being a guessing game and becomes something you can actually reason about.

reactive: Turning a Whole Object into a Monitored House

Let's start with reactive. It wraps an object into a reactive object, and under the hood it's built entirely on JavaScript's Proxy:

js
import { reactive } from 'vue'
 
const state = reactive({
  count: 0,
  user: { name: 'John' },
})
 
state.count++ // triggers reactivity
state.user.name = 'Sam' // nested properties trigger reactivity too

Think of reactive as installing a surveillance system across an entire house: any room (property) — even a room inside another room (a nested object) — gets covered, and the system picks up on anything that moves. That's what Proxy does: it intercepts operations on the whole object (get, set, deleteProperty, and so on), which is why nested properties become reactive automatically, with no extra work needed.

But that also exposes reactive's first limitation: Proxy can only wrap objects — it can't wrap a primitive value. Which means writing this doesn't accomplish anything:

js
const count = reactive(0)

Proxy intercepts operations on an object — a number has no properties to intercept in the first place. So by design, reactive only works with objects, arrays, Maps, Sets, and other collection types.

The reactive Trap: Destructuring Breaks the Surveillance System

Here's where most people get burned. If state is the monitored house, what happens when you move a room out of it and set it down on its own?

js
const state = reactive({ count: 0, name: 'John' })
 
let { count } = state
count++ // count becomes 1, but state.count is still 0, and the UI doesn't update

The problem is that state.count is just a plain number. Proxy monitors access to the state object itself, not the count value in isolation. The instant you destructure it, count becomes an ordinary standalone variable, completely disconnected from the reactivity system. This isn't a bug — it's a direct consequence of how Proxy works: it can only monitor an object, so once a value gets pulled out on its own, the surveillance simply can't follow it.

By the same logic, reassigning the whole thing also breaks:

js
let state = reactive({ count: 0 })
state = reactive({ count: 100 }) // the old reactive connection doesn't carry over to the new object

If you want to safely destructure properties off a reactive object without losing reactivity, reach for toRefs:

js
import { reactive, toRefs } from 'vue'
 
const state = reactive({ count: 0, name: 'John' })
const { count, name } = toRefs(state)
 
count.value++ // this time it correctly updates state.count

What toRefs does is wrap each property in state into its own independent ref, with a getter/setter underneath keeping a two-way link back to the original state. In other words, there's no way to destructure a reactive object while keeping reactivity without going through ref — which is exactly why understanding how ref works matters so much.

ref: Putting Any Value in a Box

ref solves exactly the pain point reactive has: it doesn't care what type of value you give it — primitives, objects, anything goes.

js
import { ref } from 'vue'
 
const count = ref(0)
const user = ref({ name: 'John' })
 
count.value++ // reading and writing both go through .value
user.value.name = 'Sam'

ref is implemented completely differently from reactive. Instead of wrapping the whole value in a Proxy, it returns an object with a .value property, defined through a getter/setter pair — reading or writing .value is what triggers dependency tracking and update notifications. Think of ref as a universal capsule: whether it holds a number, a string, or an entire object, the outer shell is always the same box, and you always have to open the .value door to get at what's inside.

If what you put inside a ref happens to be an object, Vue automatically runs it through reactive internally as well, which is why user.value.name = 'Sam' still triggers reactivity. That makes ref essentially a superset of reactive: it does everything reactive can do, plus it handles primitives on top.

Why Don't You Need .value in Templates?

If you've written any Vue 3, you've probably noticed that a ref needs .value inside <script>, but in <template> you can just write count directly:

vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
 
<template>
  <button @click="count++">{{ count }}</button>
</template>

That's because when Vue compiles the template, it detects that count is a ref and automatically inserts the .value access for you. This behavior is called top-level ref unwrapping — but it only kicks in when the ref is a top-level variable in the template.

There's a separate, independent rule too: whenever a ref is a property of a reactive object (say, reactive({ count: ref(0) })), accessing that property — whether in <script> or in the template — automatically unwraps it to the raw value, no .value needed. This is unrelated to top-level template unwrapping; it holds true even outside a template, and even for a non-top-level variable, as long as the container is a reactive object. One thing to watch out for: if a ref is nested inside a plain object or array that hasn't been passed through reactive, this auto-unwrapping doesn't happen, and you still need .value. It's a special case Vue handles deliberately, and one worth keeping straight so you don't get tripped up in more complex nested structures.

So Which One Should You Actually Use?

Once you understand the internals, the decision criteria are actually pretty intuitive. Just work through these questions:

  • Are you wrapping a primitive (string, number, boolean)? Your only option is ref — reactive simply doesn't work on primitives.
  • Will you need to reassign the whole thing? For example, swapping in an entirely new object once an async request comes back. ref is the safer choice here, since count.value = newObject always works — reassigning a reactive variable breaks the reactive connection.
  • Is it a structurally stable object whose properties are naturally related (like form data)? reactive reads and writes more naturally here, without .value cluttering every line, and it fits the semantics better too — it reads as "this is a piece of data," not "this is a box holding data."
  • Will you need to destructure out a single property to use on its own? If so, favor ref, or pair reactive with toRefs — otherwise you'll fall straight into the destructuring trap that kills reactivity.

In practice, a lot of teams settle on a simple compromise: default to ref for everything, since it covers the widest range of cases with the most consistent mental model — it's always .value, and you never have to remember which operations will break reactivity. Reach for reactive only when you're confident you're dealing with structurally stable object data that will never get wholesale-replaced, in exchange for skipping a few .values.

Vue's official docs and the community broadly agree: absent a specific reason not to, default to ref.

Wrapping Up

ref and reactive both claim to "make data reactive" on the surface, but the logic underneath is completely different. reactive relies on Proxy to monitor an entire object — nested properties react automatically and you skip writing .value, at the cost of only accepting object types, with destructuring or wholesale reassignment severing the reactive connection. ref instead puts any value into a box with a getter/setter, unifying reads and writes through .value — the trade-off is that box, in exchange for handling primitives and objects alike, plus a reactive connection that stays intact even through reassignment. Once you see this difference at the implementation level, picking an API stops being a guess and becomes something you can derive directly from the data's type and how you plan to use it.