ref, reactive, and computed
Vue 3's reactivity system is built on JavaScript Proxies, and it gives you three core primitives: ref, reactive, and computed. Choosing the right one in the right situation is not complicated, but getting it wrong leads to subtle bugs where your UI silently stops updating. This is the most important topic to internalize before writing real Vue code.
ref: The Default Choice
ref() wraps any value -- primitive or object -- in a reactive container. You access the inner value with .value in script, and Vue auto-unwraps it in templates:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const name = ref('Alice')
const tags = ref<string[]>(['vue', 'typescript'])
// .value in script
count.value++
name.value = 'Bob'
tags.value.push('nuxt')
</script>
<template>
<!-- No .value in template -->
<p>{{ name }} has {{ count }} posts tagged {{ tags.join(', ') }}</p>
</template>
ref works with every type. Primitives, arrays, objects, null -- it does not matter. When you pass an object to ref, Vue deep-wraps it in a Proxy internally, so nested property changes are tracked automatically.
This is why ref is the default. You do not need to think about what you are wrapping.
reactive: The Object-Only Alternative
reactive() returns a Proxy directly, without the .value wrapper:
<script setup lang="ts">
import { reactive } from 'vue'
const form = reactive({
email: '',
password: '',
rememberMe: false,
})
// No .value needed
form.email = 'user@example.com'
form.rememberMe = true
</script>
<template>
<input v-model="form.email" type="email" />
<input v-model="form.password" type="password" />
<label>
<input v-model="form.rememberMe" type="checkbox" /> Remember me
</label>
</template>
This looks cleaner for objects, but reactive has real constraints:
- Only works with objects, arrays, Maps, and Sets. You cannot pass a string or number.
- You lose reactivity if you reassign the variable.
form = { email: '' }breaks the Proxy connection entirely. - Destructuring kills reactivity.
const { email } = formgives you a plain string, not a reactive reference.
import { reactive } from 'vue'
const state = reactive({ count: 0 })
// This destroys reactivity. The template still points at the old Proxy.
// state = reactive({ count: 1 }) // DON'T DO THIS
// This also destroys reactivity:
const { count } = state // count is just 0, a plain number
When reactive Makes Sense
Use reactive when you have a flat object that you will only mutate properties on, never reassign. Form state is the textbook example. For everything else, ref is safer.
Some teams ban reactive entirely and use ref for everything. That is a reasonable policy. You will never hit the destructuring or reassignment traps with ref.
toRefs: Bridging reactive to ref
When you need to destructure a reactive object without losing reactivity, toRefs converts each property into a ref:
import { reactive, toRefs } from 'vue'
const state = reactive({
x: 0,
y: 0,
visible: true,
})
// Each property is now an independent ref
const { x, y, visible } = toRefs(state)
// These are reactive -- changes propagate back to state
x.value = 100
console.log(state.x) // 100
This is most useful when returning state from a composable. If your composable uses reactive internally, wrap the return in toRefs so consumers get refs they can destructure safely:
export function useMousePosition() {
const state = reactive({ x: 0, y: 0 })
function update(event: MouseEvent) {
state.x = event.clientX
state.y = event.clientY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return toRefs(state)
}
There is also toRef (singular), which converts a single property:
const x = toRef(state, 'x')
computed: Derived State
computed() creates a read-only reactive value that recalculates when its dependencies change. It is lazy and cached -- the getter only runs when a dependency changes AND the computed value is actually read:
<script setup lang="ts">
import { ref, computed } from 'vue'
interface CartItem {
name: string
price: number
quantity: number
}
const items = ref<CartItem[]>([
{ name: 'Keyboard', price: 89, quantity: 1 },
{ name: 'Mouse', price: 45, quantity: 2 },
])
const taxRate = ref(0.08)
const subtotal = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const tax = computed(() => subtotal.value * taxRate.value)
const total = computed(() => subtotal.value + tax.value)
</script>
<template>
<div v-for="item in items" :key="item.name">
{{ item.name }}: ${{ item.price }} x {{ item.quantity }}
</div>
<p>Subtotal: ${{ subtotal.toFixed(2) }}</p>
<p>Tax: ${{ tax.toFixed(2) }}</p>
<p>Total: ${{ total.toFixed(2) }}</p>
</template>
Computed values chain naturally. total depends on subtotal, which depends on items. Change any item's quantity, and all three recompute in order. Vue's dependency graph handles this automatically.
Writable Computed
Occasionally you need a computed that can be set directly. A common case is a computed that transforms data in both directions:
<script setup lang="ts">
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value: string) => {
const parts = value.split(' ')
firstName.value = parts[0] || ''
lastName.value = parts.slice(1).join(' ') || ''
},
})
</script>
<template>
<input v-model="fullName" />
<p>First: {{ firstName }}, Last: {{ lastName }}</p>
</template>
Writable computeds are rare. If you find yourself reaching for them often, you are probably overcomplicating things.
shallowRef: Opting Out of Deep Reactivity
ref makes nested objects deeply reactive. For large objects or objects managed by external libraries, this is wasteful or even harmful. shallowRef only tracks the .value assignment itself, not nested property changes:
import { shallowRef, triggerRef } from 'vue'
// A large dataset from an API
const tableData = shallowRef<Row[]>([])
// This triggers reactivity (new .value assignment)
tableData.value = await fetchRows()
// This does NOT trigger reactivity (nested mutation)
tableData.value[0].name = 'Updated'
// If you need to force a re-render after mutation:
triggerRef(tableData)
Use shallowRef when wrapping objects that should not be deeply proxied -- chart.js instances, large datasets you replace wholesale, or DOM elements.
ref vs reactive: The Decision
The community has largely settled this: use ref for everything. Here is why:
refworks with all types.reactiveonly works with objects.refis explicit. The.valueis annoying, but it makes reactivity visible. You always know when you are dealing with reactive state.refdoes not break when destructured or reassigned.- Composables should return refs, not reactive objects.
The .value tax is real, but it is a small price for consistency and safety. The Vue team themselves recommend ref as the default.
Common Pitfalls
- Forgetting
.valuein script: This is the number one Vue 3 bug.count++does nothing ifcountis a ref. You needcount.value++. Your IDE will catch this if you have Volar installed, so install Volar. - Reassigning a
reactivevariable:state = newStatesilently breaks reactivity. The template still references the old Proxy. UseObject.assign(state, newState)to copy properties over, or just useref. - Destructuring
reactivewithouttoRefs:const { name } = stategives you a snapshot, not a reactive binding. The template will never update. - Using
computedfor side effects: Computed getters should be pure functions. Do not make API calls or mutate state inside a computed. UsewatchorwatchEffectfor side effects. - Overusing
shallowRef: Only reach forshallowRefwhen you have a specific performance concern. Premature optimization withshallowRefleads to confusing bugs where nested changes are silently ignored.
Key Takeaways
refis the default reactive primitive. It works with any type and is safe to destructure and reassign.reactiveis useful for flat objects like form state, but has footguns around reassignment and destructuring.computedcreates lazy, cached derived values. Use it for any value that can be calculated from other reactive state.toRefsconvertsreactiveproperties into individual refs, preserving reactivity through destructuring.shallowRefskips deep reactivity, useful for large objects or third-party instances you replace wholesale.- When in doubt, use
ref. The.valuesyntax is a minor inconvenience that prevents real bugs.