Vue 3 gives you two ways to create reactive state: ref and reactive. They both make data reactive so the template updates when it changes, but they work differently and have different constraints.
ref: works with any value
ref wraps any value: a number, a string, a boolean, an object, an array, or null. You access and modify it through .value in JavaScript/TypeScript. In templates, Vue unwraps it automatically so you don't write .value there.
import { ref } from 'vue'
const count = ref(0) // primitive
const user = ref({ name: '' }) // object
const items = ref<string[]>([]) // array
count.value++ // access through .value in JS
user.value.name = 'Ana' // nested access
items.value.push('new item')<template>
<!-- No .value needed in templates -->
<p>{{ count }}</p>
<p>{{ user.name }}</p>
</template>reactive: objects only, no .value
reactive wraps an object (or array, Map, Set) and makes its properties reactive directly, no .value needed. But it only works with objects. You can't pass it a number or string.
import { reactive } from 'vue'
const state = reactive({ count: 0, name: '' })
state.count++ // no .value needed
state.name = 'Ana'The big limitation: you cannot reassign a reactive object. If you do state = newObject, you break the reactive link. Watchers and the template still reference the old object.
let state = reactive({ count: 0 })
state = reactive({ count: 1 }) // ❌ breaks reactivity — old watchers still watch the old proxyWhen to use which
Use ref for everything is the safest default. It works with any type, you can reassign it freely (count.value = newValue), and it handles all edge cases. The .value is a small cost for flexibility.
Use reactive when you have a group of related properties that always live together and you'll never replace the whole object, like a form:
const form = reactive({
email: '',
password: '',
remember: false
})
form.email = 'ana@example.com' // ergonomic, no .valueThe gotchas
Destructuring a reactive breaks reactivity:
const state = reactive({ count: 0 })
const { count } = state // ❌ count is now a plain number (0), not reactive
// Use toRefs() if you need to destructureReassigning a ref is fine, reassigning a reactive is not:
const data = ref<User[]>([])
data.value = await fetchUsers() // ✅ works perfectly
let data = reactive<User[]>([])
data = await fetchUsers() // ❌ breaks reactive linkThis is why ref is the recommended default. When you need to replace the entire value (API responses, resetting state), ref just works.
See also: What's the difference between computed and watch? · Why do I lose reactivity when destructuring?
References
- ref() - Vue.js docs
- reactive() - Vue.js docs
- Reactivity Fundamentals - Vue.js docs