There's a question every Vue developer asks within the first month of using the Composition API: "Should I use ref or reactive?" And the answer they get is some version of "it depends" which doesn't help anyone.

Both wrap data in reactivity. Both update the template when the value changes. Both have escape hatches and gotchas. The difference is in how they behave when you start moving the value around — destructuring it, passing it to a composable, replacing it wholesale.

This article is the practical decision guide. Not an opinion piece — a list of scenarios, what each one does in each shape, and which choice ages well.

What Each One Actually Is

ref(value) returns a reactive wrapper object: { value: T }. You read and write through .value. In templates, Vue's compiler unwraps it automatically (you write {{ count }}, not {{ count.value }}). In script code, you always need .value.

reactive(obj) returns a Proxy over obj. You read and write properties directly. No .value. Only works on objects, arrays, Map, Set — not on primitives.

TypeScript
import { ref, reactive } from 'vue'

const count = ref(0)
count.value++              // .value required outside templates

const state = reactive({ count: 0 })
state.count++              // direct property access

That's the syntactic difference. The behavioural differences come out the moment you try to do anything with the value other than read and write it in the same scope.

The Five Scenarios That Decide The Choice

1. A Single Primitive — Use ref

TypeScript
const query = ref('')
const isOpen = ref(false)
const userId = ref<number | null>(null)

reactive doesn't even work here — it requires an object. Some people wrap primitives in objects (reactive({ value: '' })) to avoid .value, but you've reinvented ref and made the code harder to read.

2. A Small, Internal Object — Use reactive

TypeScript
const form = reactive({
  name: '',
  email: '',
  acceptTerms: false,
})

form.name = 'Ann'           // no .value, mutations are direct

reactive reads more naturally for objects. ref({ name: '' }).value.name = 'Ann' works but the .value. is friction. If the object stays inside one component or one composable, reactive is the cleaner choice.

3. A Value Returned From A Composable — Use ref

This is the scenario where the choice has consequences:

TypeScript
// composable
export function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment }
}

// component
const { count, increment } = useCounter()
console.log(count.value)   // ✅ still reactive after destructuring

ref wraps the value in an object, so when you destructure the return, you're copying the object reference — not the underlying value. The reference still points at the same RefImpl, reactivity stays alive.

reactive doesn't survive destructuring:

TypeScript
export function useCounter() {
  const state = reactive({ count: 0 })
  return state              // returning the proxy
}

const { count } = useCounter()
console.log(count)         // 0 — but it's a plain number now, not reactive

The destructuring at the call site reads the count property once and binds it to a local variable. Future mutations to state.count don't update count.

The fix is toRefs:

TypeScript
import { reactive, toRefs } from 'vue'

export function useCounter() {
  const state = reactive({ count: 0 })
  return toRefs(state)      // each property becomes a ref bound to the proxy
}

const { count } = useCounter()
console.log(count.value)   // ✅ reactive again

toRefs walks the object and replaces each property with a ref that reads/writes through the original Proxy. Destructure-safe.

The official Vue team recommendation tilts toward ref for composable returns specifically because it sidesteps this trap — and the destructuring case is constant in real code.

4. A Value You'll Replace Wholesale — Use ref

TypeScript
const user = ref<User | null>(null)

async function loadUser(id: number) {
  user.value = await api.getUser(id)    // single assignment
}

ref lets you replace the entire value with one line. With reactive, reassigning the local variable doesn't work (you're just rebinding the local), and you have to Object.assign(state, newState) to mutate properties on the original Proxy:

TypeScript
const state = reactive<User>({ id: 0, name: '' })

state = await api.getUser(id)              // ❌ doesn't update reactivity
Object.assign(state, await api.getUser(id))  // ✅ but verbose

Anything you'd genuinely "swap out" — current user, current page data, current document — fits better with ref.

5. A Large, Performance-Sensitive Object — Use shallowRef Or markRaw

For data you display but rarely mutate (large tables, pre-fetched JSON blobs, third-party class instances), the deep-Proxy traversal of reactive costs memory and time. Two escape hatches:

TypeScript
import { shallowRef, markRaw } from 'vue'

// shallowRef: only the .value swap is reactive; nested mutations aren't tracked
const tableData = shallowRef(rows)
tableData.value = newRows    // triggers update
tableData.value[0].name = 'x' // does NOT trigger update

// markRaw: permanently opt out — useful for class instances from libs
const player = markRaw(new VideoPlayer())   // Vue won't try to wrap it

The discipline that pairs with shallowRef is immutable updates — replace the whole reference, never mutate inside. Slightly more work at the call site, dramatically cheaper for big data.

A 4×2 matrix comparing ref and reactive across four real scenarios — single primitive, small object, returned from a composable, replaced wholesale — with a green check, amber warning, or red cross in each cell.
Same data, two wrappers, very different behaviour the moment you move the value around.

The Decision Tree, In One Place

When you're picking between them:

  1. Is the value a primitive?ref. (reactive doesn't work.)
  2. Will it cross a composable boundary or be destructured?ref (or reactive + toRefs).
  3. Will you replace the whole value at once?ref.
  4. Is it a stable, internal object that stays in one place?reactive reads nicer.
  5. Is it a huge data structure or third-party class?shallowRef or markRaw after measuring.

For new code, I default to ref. The .value cost is small; the destructuring trap is large. Once a codebase has dozens of composables and hundreds of components, "one consistent wrapper everywhere" is worth more than "the slightly nicer syntax for objects".

The Vue Team's Own Take

The official Vue docs recommend ref as the default — primarily to keep code consistent and to sidestep the destructuring trap, especially in composables. That's not a hard rule. There are real cases where reactive is fine (form objects that stay inside one component, internal store state). But "default to ref" is the path of least surprise.

A Few Subtle Bugs Worth Knowing

  • watch(state.count, ...) doesn't work because state.count is a plain number at the time of the call, not a reactive source. Use () => state.count to make it a getter, or watch(toRef(state, 'count'), ...).
  • Templates auto-unwrap top-level refs but don't unwrap refs inside arrays or nested objects. arr.value[0] displays correctly; arr[0].value is what you'd write in script.
  • ref of an object does deep reactivity by default — ref({ items: [] }).value.items.push(...) triggers updates. (shallowRef opts out.)
  • reactive(null) warns in dev (value cannot be made reactive) and returns the value unchanged — same behaviour as reactive(primitive).
  • isRef, isReactive, isProxy exist for runtime checks when you genuinely need them. You usually don't.

The One-Sentence Mental Model

ref is a reactive box that survives moving around. reactive is a reactive view of an object that's pinned in place. Move the value across boundaries — ref. Keep it local and treat it like state — reactive is fine, but the consistency wins of using ref everywhere usually outweigh the prettier syntax. Pick once, use it everywhere, and the question stops coming up.