·
9 min read

Ref vs. Reactive: What to Choose Using Vue 3 Composition API?

Ref vs. Reactive: What to Choose Using Vue 3 Composition API? Image

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 and reactive 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.

Info

You can skip this chapter if you already know how Vue 3's reactivity system works.

Unfortunately, JavaScript is not reactive per default. Let's take a look at the following code example:

index.js
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:

pseudo-code.js
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({}))

Info

If you want to go deeper, take a look at the 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>

Info

This only works if the 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:

Volar Autocomplete Refs

The corresponding JSON setting:

json
"volar.autoCompleteRefs": true

Info

To reduce CPU usage, this feature is disabled by default.

Summarizing comparison between reactive() and ref()

Let's take a summarizing look at the differences between reactive and ref:

reactiveref
👎 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:

OptionsApiComponent.vue
<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:

CompositionApiComponent.vue
<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.

Info

This pattern is also used in libraries like Vuelidate where they use reactive() for setting up state for validations.

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.

I will never share any of your personal data. You can unsubscribe at any time.

If you found this article helpful.You will love these ones as well.
Rendering Dynamic Markdown in Nuxt 3+ Image

Rendering Dynamic Markdown in Nuxt 3+

Analyze Memory Leaks in Your Nuxt App Image

Analyze Memory Leaks in Your Nuxt App

Self-Host Your Nuxt App With Coolify Image

Self-Host Your Nuxt App With Coolify

Simpler Two-Way Binding in Vue With defineModel Image

Simpler Two-Way Binding in Vue With defineModel