4 min read
On this page

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 } = form gives 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:

  • ref works with all types. reactive only works with objects.
  • ref is explicit. The .value is annoying, but it makes reactivity visible. You always know when you are dealing with reactive state.
  • ref does 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 .value in script: This is the number one Vue 3 bug. count++ does nothing if count is a ref. You need count.value++. Your IDE will catch this if you have Volar installed, so install Volar.
  • Reassigning a reactive variable: state = newState silently breaks reactivity. The template still references the old Proxy. Use Object.assign(state, newState) to copy properties over, or just use ref.
  • Destructuring reactive without toRefs: const { name } = state gives you a snapshot, not a reactive binding. The template will never update.
  • Using computed for side effects: Computed getters should be pure functions. Do not make API calls or mutate state inside a computed. Use watch or watchEffect for side effects.
  • Overusing shallowRef: Only reach for shallowRef when you have a specific performance concern. Premature optimization with shallowRef leads to confusing bugs where nested changes are silently ignored.

Key Takeaways

  • ref is the default reactive primitive. It works with any type and is safe to destructure and reassign.
  • reactive is useful for flat objects like form state, but has footguns around reassignment and destructuring.
  • computed creates lazy, cached derived values. Use it for any value that can be calculated from other reactive state.
  • toRefs converts reactive properties into individual refs, preserving reactivity through destructuring.
  • shallowRef skips deep reactivity, useful for large objects or third-party instances you replace wholesale.
  • When in doubt, use ref. The .value syntax is a minor inconvenience that prevents real bugs.