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.

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

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

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:

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

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)
3
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,
3})
4
5// ⚠️ count is now a local variable disconnected from state.count
6let { count } = state
7
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,
3})
4
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,
3})
4
5watch(state, () => console.log(state), { deep: true })
6// "{ count: 0 }"
7
8// ⚠️ The above reference ({ count: 0 }) is no longer being tracked (reactivity connection is lost!)
9state = reactive({
10  count: 10,
11})
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,
3})
4
5const useFoo = (count) => {
6  // ⚠️ Here count is a plain number and the useFoo composable
7  // cannot track changes to state.count
8}
9
10useFoo(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:

1import { ref } from 'vue'
2
3const count = ref(0)
4const state = ref({ count: 0 })

To read & write the reactive variable created with ref(), you need to access it with the .value property:

1const count = ref(0)
2const state = ref({ count: 0 })
3
4console.log(count) // { value: 0 }
5console.log(count.value) // 0
6
7count.value++
8console.log(count.value) // 1
9
10state.value.count = 1
11console.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():

1function ref(value) {
2  const refObject = {
3    get value() {
4      track(refObject, 'value')
5      return value
6    },
7    set value(newValue) {
8      value = newValue
9      trigger(refObject, 'value')
10    },
11  }
12  return refObject
13}

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:

1import { ref } from 'vue'
2
3const count = ref(0)
4
5const countValue = count.value // ⚠️ disconnects reactivity
6const { value: countDestructured } = count // ⚠️ disconnects reactivity

But reactivity is not lost if refs are grouped in a plain JavaScript object:

1const state = {
2  count: ref(1),
3  name: ref('Michael'),
4}
5
6const { count, name } = state // still reactive

Refs can also be passed into functions without losing reactivity.

1const state = {
2  count: ref(1),
3  name: ref('Michael'),
4}
5
6const useFoo = (count) => {
7  /**
8   * The function receives a ref
9   * It needs to access the value via .value but it
10   * will retain the reactivity connection
11   */
12}
13
14useFoo(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:

1const state = {
2  count: 1,
3  name: 'Michael',
4}
5
6// Still reactive
7state.value = {
8  count: 2,
9  name: 'Chris',
10}

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:

1import { ref, unref } from 'vue'
2
3const count = ref(0)
4
5const unwrappedCount = unref(count)
6// 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:

1<script setup>
2import { ref } from 'vue'
3
4const count = ref(0)
5</script>
6
7<template>
8  <span>
9    <!-- no .value needed -->
10    {{ count }}
11  </span>
12</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:

1import { watch, ref } from 'vue'
2
3const count = ref(0)
4
5// Vue automatically unwraps this ref for us
6watch(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:

1anyObject.property = 'new' // anyObject could be a plain JS object or a reactive object
2
3anyRef.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:

1const dataFromApi = { value: 'abc', name: 'Test' }
2
3const reactiveData = ref(dataFromApi)
4
5const 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
1<script>
2export default {
3  data() {
4    count: 0,
5    name: 'MyCounter'
6  },
7  methods: {
8    increment() {
9      this.count += 1;
10    },
11  }
12};
13</script>

You can simply copy everything from data into reactive to migrate this component to Composition API:

CompositionApiComponent.vue
1<script setup>
2setup() {
3  // Equivalent to "data" in Options API
4  const state = reactive({
5    count: 0,
6    name: 'MyCounter'
7  });
8  const {count, name} = toRefs(statee)
9
10  // Equivalent to "methods" in Options API
11  increment(username) {
12    state.count += 1;
13  }
14}
15</script>

Composing ref and reactive

A recommended pattern is to group refs inside a reactive object:

1const loading = ref(true)
2const error = ref(null)
3
4const state = reactive({
5  loading,
6  error,
7})
8
9// You can watch the reactive object...
10watchEffect(() => console.log(state.loading))
11
12// ...and the ref directly
13watch(loading, () => console.log('loading has changed'))
14
15setTimeout(() => {
16  loading.value = false
17  // Triggers both watchers
18}, 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.
Unlocking the Power of v-for Loops in Vue With These Useful Tips Image

Unlocking the Power of v-for Loops in Vue With These Useful Tips

Focus & Code Diff in Nuxt Content Code Blocks Image

Focus & Code Diff in Nuxt Content Code Blocks

Lazy Load Vue Component When It Becomes Visible Image

Lazy Load Vue Component When It Becomes Visible

A Comprehensive Guide to Data Fetching in Nuxt 3 Image

A Comprehensive Guide to Data Fetching in Nuxt 3