Javascript is required
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.


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:

1let price = 10.0
2const quantity = 2
4const total = price * quantity
5console.log(total) // 20
7price = 20.0
8console.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:

1function reactive(obj) {
2  return new Proxy(obj, {
3    get(target, key) {
4      track(target, key)
5      return target[key]
6    },
7    set(target, key, value) {
8      target[key] = value
9      trigger(target, key)
10    },
11  })

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.


Let's now analyze how you can use Vue 3's reactive() function to declare a reactive state:

1import { reactive } from 'vue'
3const 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:

1import { reactive } from 'vue'
3const state = reactive({
4  count: 0,
5  nested: { count: 0 },
8watch(state, () => console.log(state))
9// "{ count: 0, nested: { count: 0 } }"
11const incrementNestedCount = () => {
12  state.nested.count += 1
13  // 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:

1const plainJsObject = {}
2const proxy = reactive(plainJsObject)
4// proxy is NOT equal to the original plain JS object.
5console.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:

1const state = reactive({
2  count: 0,
5// ⚠️ count is now a local variable disconnected from state.count
6let { count } = state
8count += 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:

1let state = reactive({
2  count: 0,
5// count is a ref, maintaining reactivity
6const { 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:

1const state = reactive({
2  count: 0,
5watch(state, () => console.log(state), { deep: true })
6// "{ count: 0 }"
8// ⚠️ The above reference ({ count: 0 }) is no longer being tracked (reactivity connection is lost!)
9state = reactive({
10  count: 10,
12// ⚠️ The watcher doesn't fire

The reactivity connection is also lost if we pass a property into a function:

1const state = reactive({
2  count: 0,
5const useFoo = (count) => {
6  // ⚠️ Here count is a plain number and the useFoo composable
7  // cannot track changes to state.count