SupportWriter space ↗
← Back to the journal
Vue.js

Vue 3 reactivity: ref, reactive, computed, and watch

Understand how Vue tracks data changes, when to use ref or reactive, and the difference between computed and watch, along with common pitfalls.

DAdministrator · 01 Oct 2026 · 5 min read

How does reactivity work?

Reactivity is the heart of Vue. While a component renders, Vue records which reactive data is read. When any of that data changes, Vue schedules a re-render of only the components that need it. In Vue 3 this mechanism is built on JavaScript Proxy objects and getters/setters.

For this to work, data must be created with a reactivity API such as ref or reactive. A plain variable (let total = 0) will not trigger UI updates.

ref: the default choice

ref wraps any value, primitive or object, in an object with a .value property.

<script setup>
import { ref } from 'vue'

const title = ref('Learning Vue')
const tags = ref(['vue', 'javascript'])

function addTag(tag) {
  tags.value.push(tag)
}
</script>

<template>
  <h2>{{ title }}</h2>
  <p>{{ tags.join(', ') }}</p>
  <button type="button" @click="addTag('frontend')">Add tag</button>
</template>

Inside <script> you must write .value. In the template, top-level refs are unwrapped automatically, so you don't need .value. When a ref holds an object or array, its contents are deeply reactive too, so tags.value.push() still triggers an update.

reactive: for objects

reactive turns an object into a reactive proxy without .value:

import { reactive } from 'vue'

const form = reactive({
  email: '',
  password: '',
  rememberMe: false,
})

form.email = 'user@example.com'

However, reactive has some limitations:

  1. It only works with objects, arrays, Map, and Set, not strings or numbers.
  2. The variable must not be replaced entirely. form = reactive({...}) breaks the connection with the UI.
  3. Destructuring loses reactivity.
const { email } = form // email is now a plain string, not reactive

If you need to destructure, use toRefs:

import { toRefs } from 'vue'

const { email, password } = toRefs(form)
email.value = 'new@example.com' // stays in sync with form.email

Because of these limitations, the official Vue documentation recommends ref as the primary API for declaring state.

computed: cached derived values

If a value can be calculated from other data, use computed. Its result is cached and only recalculated when its dependencies change.

<script setup>
import { computed, ref } from 'vue'

const cart = ref([
  { name: 'Vue book', price: 25, qty: 1 },
  { name: 'Mouse', price: 18, qty: 2 },
])

const totalPrice = computed(() =>
  cart.value.reduce((total, item) => total + item.price * item.qty, 0)
)

const formatCurrency = (amount) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
</script>

<template>
  <p>Total: {{ formatCurrency(totalPrice) }}</p>
</template>

Important rules for computed:

  • No side effects: don't call APIs or modify other state inside it.
  • Treat the result as read-only. If you really need a writable value, use the computed({ get, set }) form.
  • Compared with a method: a method runs every time the component renders, while a computed property only recalculates when its dependencies change.

watch: running side effects

Use watch when you need to do something in response to a change, such as fetching data from an API or saving to localStorage.

import { ref, watch } from 'vue'

const keyword = ref('')
const results = ref([])

watch(keyword, async (newValue, oldValue, onCleanup) => {
  if (newValue.length < 3) {
    results.value = []
    return
  }

  const controller = new AbortController()
  onCleanup(() => controller.abort())

  const response = await fetch(`/api/articles?q=${encodeURIComponent(newValue)}`, {
    signal: controller.signal,
  })
  results.value = await response.json()
})

onCleanup aborts the previous request if the keyword changes again before the response arrives, so stale results never overwrite fresh ones.

Some commonly used options:

watch(source, callback, { immediate: true }) // run once right away
watch(reactiveObject, callback, { deep: true }) // watch changes inside an object
watch(() => form.email, callback) // watch a single property of a reactive object

Note the last example: to watch a property of a reactive object, pass a getter function. Writing watch(form.email, ...) does not work because only a plain string is passed.

watchEffect

watchEffect runs a function immediately and automatically tracks every reactive value read inside it:

import { watchEffect } from 'vue'

watchEffect(() => {
  localStorage.setItem('cart', JSON.stringify(cart.value))
})

Use watch when you want explicit control over the source being watched and access to the old value. Use watchEffect when the effect depends on many values and you don't need the old value.

DOM updates are asynchronous

Vue batches multiple changes into a single update cycle for efficiency. As a result, the DOM is not updated immediately after state changes. Use nextTick when you need to read the updated DOM:

import { nextTick, ref } from 'vue'

const isOpen = ref(false)

async function openPanel() {
  isOpen.value = true
  await nextTick()
  document.querySelector('#panel input')?.focus()
}

Summary

Need API
Simple state or objects ref
Objects without .value reactive
Derived values computed
Side effects with a specific source watch
Side effects with automatic tracking watchEffect

Exercise

Build a temperature converter with a single ref for Celsius, then display Fahrenheit and Kelvin using computed. Add a watch that saves the latest Celsius value to localStorage.

← Explore more notes