
Ref vs. Reactive: What to Choose Using Vue 3 Composition API?
Michael Hoffmann
@mokkapps

I love Vue 3's Composition API, but it provides two approaches to adding a reactive state to Vue components: ref
and reactive
. It can be cumbersome to use .value
everywhere when using refs but you can also easily lose reactivity when destructuring reactive objects created with reactive
.
In this article, I'll explain how you can choose whether to utilize reactive
, ref
, or both.
TL;DR: Use
ref
by default andreactive
when you need to group things.
Reactivity in Vue 3
Before I explain ref
and reactive
, you should understand Vue 3's reactivity system basics.
Unfortunately, JavaScript is not reactive per default. Let's take a look at the following code example:
let price = 10.0
const quantity = 2
const total = price * quantity
console.log(total) // 20
price = 20.0
console.log(total) // ⚠️ total is still 20
In a reactivity system, we expect that total
is updated each time price
or quantity
is changed. But JavaScript usually doesn't work like this.
You might ask yourself, why does Vue need a reactivity system? The answer is simple: The state of a Vue component consists of reactive JavaScript objects. When you modify them, the view or dependent reactive objects are updated.
Therefore, the Vue framework had to implement another mechanism to track the reading and writing of local variables, and it's done by intercepting the reading and writing of object properties. This way, Vue can track a reactive object's property access and mutations.
Due to browser limitations, Vue 2 used getters/setters exclusively to intercept properties. Vue 3 uses Proxies for reactive objects and getters/setters for refs. The following pseudo-code shows the basics of property interception; it should explain the core concept and ignores many details and edge cases:
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key)
},
})
}
The get
/ set
methods of the proxy are often called proxy traps.
I recommend reading the official documentation for more details about Vue's reactivity system.
reactive()
Let's now analyze how you can use Vue 3's reactive()
function to declare a reactive state:
import { reactive } from 'vue'
const state = reactive({ count: 0 })
This state is deeply reactive by default. If you mutate nested arrays or objects, these changes will be detected by Vue:
import { reactive } from 'vue'
const state = reactive({
count: 0,
nested: { count: 0 },
})
watch(state, () => console.log(state))
// "{ count: 0, nested: { count: 0 } }"
const incrementNestedCount = () => {
state.nested.count += 1
// Triggers watcher -> "{ count: 0, nested: { count: 1 } }"
}
Limitations of reactive()
The reactive()
API has two limitations:
The first limitation is that it only works on object types like objects, arrays, and collection types such as Map
and Set
. It doesn't work with primitive types such as string
, number
, or boolean
.
The second limitation is that the returned proxy object from reactive()
doesn't have the same identity as the original object. A comparison with the ===
operator returns false
:
const plainJsObject = {}
const proxy = reactive(plainJsObject)
// proxy is NOT equal to the original plain JS object.
console.log(proxy === plainJsObject) // false
You must always keep the same reference to the reactive object otherwise, Vue cannot track the object's properties. You might be confronted with this problem if you try to destructure a reactive object's property into local variables:
const state = reactive({
count: 0,
})
// ⚠️ count is now a local variable disconnected from state.count
let { count } = state
count += 1 // ⚠️ Does not affect original state
Luckily, you can use toRefs
to convert all of the object's properties into refs first, and then you can destructure without losing reactivity:
let state = reactive({
count: 0,
})
// count is a ref, maintaining reactivity
const { count } = toRefs(state)
A similar problem occurs if you try to reassign reactive
value. If you "replace" a reactive object, the new object overwrites the reference to the original object, and the reactive connection is lost:
const state = reactive({
count: 0,
})
watch(state, () => console.log(state), { deep: true })
// "{ count: 0 }"
// ⚠️ The above reference ({ count: 0 }) is no longer being tracked (reactivity connection is lost!)
state = reactive({
count: 10,
})
// ⚠️ The watcher doesn't fire
The reactivity connection is also lost if we pass a property into a function:
const state = reactive({
count: 0,
})
const useFoo = (count) => {
// ⚠️ Here count is a plain number and the useFoo composable
// cannot track changes to state.count
}
useFoo(state.count)
ref()
Vue provides a ref()
function that addresses the limitations of reactive()
.
ref()
is not limited to object types but can hold any value type:
import { ref } from 'vue'
const count = ref(0)
const state = ref({ count: 0 })
To read & write the reactive variable created with ref()
, you need to access it with the .value
property:
const count = ref(0)
const state = ref({ count: 0 })
console.log(count) // { value: 0 }
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
state.value.count = 1
console.log(state.value) // { count: 1 }
You might ask yourself how ref()
can hold primitive types as we just learned that Vue needs an object to be able to trigger the get/set proxy traps. The following pseudo-code shows the simplified logic behind ref()
:
function ref(value) {
const refObject = {
get value() {
track(refObject, 'value')
return value
},
set value(newValue) {
value = newValue
trigger(refObject, 'value')
},
}
return refObject
}
When holding object types, ref
automatically converts its .value
with reactive()
:
ref({}) ~= ref(reactive({}))
ref()
implementation in Vue's source code.Unfortunately, it's also not possible to destructure a reactive object created with ref()
. It leads to loss of reactivity as well:
import { ref } from 'vue'
const count = ref(0)
const countValue = count.value // ⚠️ disconnects reactivity
const { value: countDestructured } = count // ⚠️ disconnects reactivity
But reactivity is not lost if refs are grouped in a plain JavaScript object:
const state = {
count: ref(1),
name: ref('Michael'),
}
const { count, name } = state // still reactive
Refs can also be passed into functions without losing reactivity.
const state = {
count: ref(1),
name: ref('Michael'),
}
const useFoo = (count) => {
/**
* The function receives a ref
* It needs to access the value via .value but it
* will retain the reactivity connection
*/
}
useFoo(state.count)
This capability is quite important as it is frequently used when extracting logic into Composable Functions
A ref
containing an object value can reactively replace the entire object:
const state = {
count: 1,
name: 'Michael',
}
// Still reactive
state.value = {
count: 2,
name: 'Chris',
}
Unwrapping refs()
It can be cumbersome to use .value
everywhere when using refs but we can use some helper functionality.
unref Utility function
unref() is a handy utility function that is especially useful if your value could be a ref
. Calling .value
on a non-ref
value would throw a runtime error, unref()
comes in handy in such situations:
import { ref, unref } from 'vue'
const count = ref(0)
const unwrappedCount = unref(count)
// same as isRef(count) ? count.value : count`
unref()
returns the inner value if the argument is a ref
, otherwise, it returns the argument itself. It's a sugar function for val = isRef(val) ? val.value : val
.
Template unwrapping
Vue automatically "unwraps" a ref
by applying unref() when you call it in a template. This way you never need to use access .value
in the template:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<span>
<!-- no .value needed -->
{{ count }}
</span>
</template>
ref
is a top-level property in the template.Watcher
We can directly pass a ref
as a watcher dependency:
import { watch, ref } from 'vue'
const count = ref(0)
// Vue automatically unwraps this ref for us
watch(count, (newCount) => console.log(newCount))
Volar
If you are using VS Code, you can configure the Volar extension to automatically add .value
to refs. You can enable it in the settings under Volar: Auto Complete Refs
:
The corresponding JSON setting:
"volar.autoCompleteRefs": true
Summarizing comparison between reactive() and ref()
Let's take a summarizing look at the differences between reactive
and ref
:
reactive | ref |
---|---|
👎 only works on object types | 👍 works with any value |
👍 no difference in accessing values in <script> and <template> | 👎 accessing values in <script> and <template> behaves differently |
👎 re-assigning a new object "disconnects" reactivity | 👍 object references can be reassigned |
🫱 properties can be accessed without .value | 🫱 need to use .value to access properties |
👍 references can be passed across functions | |
👎 destructured values are not reactive | |
👍 Similar to Vue 2’s data object |
My Opinion
What I like most about ref
is that you know that it's a reactive value if you see that its property is accessed via .value
. It's not that clear if you use an object that is created with reactive
:
anyObject.property = 'new' // anyObject could be a plain JS object or a reactive object
anyRef.value = 'new' // likely a ref
This assumption only is valid, if you have a basic understanding of ref
and know that you read the reactive variable with .value
.
If you are using ref
you should try to avoid using non-reactive objects that have a value
property:
const dataFromApi = { value: 'abc', name: 'Test' }
const reactiveData = ref(dataFromApi)
const valueFromApi = reactiveData.value.value // 🤮
If you are new to Composition API, reactive
might be more intuitive and it is quite handy if you try to migrate a component from Options API to Composition API. reactive
works very similarly to reactive properties inside of the data
field:
<script>
export default {
data() {
count: 0,
name: 'MyCounter'
},
methods: {
increment() {
this.count += 1;
},
}
};
</script>
You can simply copy everything from data
into reactive
to migrate this component to Composition API:
<script setup>
setup() {
// Equivalent to "data" in Options API
const state = reactive({
count: 0,
name: 'MyCounter'
});
const {count, name} = toRefs(statee)
// Equivalent to "methods" in Options API
increment(username) {
state.count += 1;
}
}
</script>
Composing ref and reactive
A recommended pattern is to group refs inside a reactive
object:
const loading = ref(true)
const error = ref(null)
const state = reactive({
loading,
error,
})
// You can watch the reactive object...
watchEffect(() => console.log(state.loading))
// ...and the ref directly
watch(loading, () => console.log('loading has changed'))
setTimeout(() => {
loading.value = false
// Triggers both watchers
}, 500)
If you don't need the reactivity of the state
object itself you could instead group the refs in a plain JavaScript object.
Grouping refs results in a single object that is easier to handle and keeps your code organized. At a glance, you can see that the grouped refs belong together and are related.
Opinions from Vue Community
The amazing Michael Thiessen wrote a brilliant in-depth article about this topic and collected the opinions of famous people in the Vue community.
Summarized, they all use ref
by default and use reactive
when they need to group things.
Conclusion
So, should you use ref
or reactive
?
My recommendation is to use ref
by default and reactive
when you need to group things. The Vue community has the same opinion but it's totally fine if you decide to use reactive
by default.
Both ref
and reactive
are powerful tools to create reactive variables in Vue 3. You can even use both of them without any technical drawbacks. Just pick the one you like and try to stay consistent in how you write your code!
If you liked this article, follow me on Twitter to get notified about new blog posts and more content from me.
Alternatively (or additionally), you can subscribe to my weekly Vue newsletter.