Telerik blogs

A hands-on guide to understanding Vue 3’s reactivity system. Learn how ref, reactive, computed and watch work together to keep your UI automatically in sync with your data. Includes practical examples, gotchas and best practices for mastering reactivity in Vue.

If you’ve ever wondered why Vue feels so effortless, why your UI just knows when to update, it all boils down to one thing: reactivity. Vue’s reactivity system is the magic behind its simplicity. It turns plain JavaScript data into a living, self-updating state that automatically keeps your UI in sync with your logic.

In this guide, we’ll go beyond “it just works” and unpack how Vue 3’s reactivity actually functions, what makes it so intuitive, what’s happening under the hood and how you can use it effectively in your own components.

What Reactivity Means (and Why It’s Awesome)

Before Vue (and frameworks like it), updating the UI meant manually syncing your data and the DOM. Change a value? You’d have to find the element, update the text and keep track of what’s what.

Vue’s reactivity flips that process on its head. Now, your data becomes the single source of truth, and Vue automatically keeps your UI in sync with it. Change the data, and the DOM reacts. That’s the essence of reactivity.

Here’s a quick taste:

import { reactive } from 'vue'
const state = reactive({ count: 0 })

function increment() {
  state.count++
  // The DOM updates automatically
}

For example, after you make state reactive, you no longer need to manually update the DOM; Vue handles this using dependency tracking.

Behind the scenes, Vue 3’s reactivity system is powered by modern JavaScript Proxies. When you make an object reactive, Vue wraps it in a proxy that “intercepts” reads and writes:

  • When you read a property, Vue tracks that dependency.
  • When you change it, Vue knows exactly which parts of the DOM depend on it and updates them efficiently.
    Think of it as a super-smart observer pattern baked directly into your data layer.

The Core APIs You’ll Use

Let’s walk through the main tools Vue provides to make your data reactive and useful.

reactive(): Make Objects Reactive

Use reactive() when working with objects, arrays, or anything with multiple properties.

import { reactive } from 'vue'

const user = reactive({
  name: 'David',
  age: 25
})

user.age++ // reactivity in action

Every nested property inside reactive() is tracked automatically, giving you deep reactivity out of the box.

ref(): Reactivity for Primitives

Objects work great with reactive, but what about primitives like numbers, strings or booleans?

That’s where ref() comes in:

import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

Notice value? That’s how you access the actual value inside a ref. Vue adds this layer because primitive values (like numbers or strings) can’t be wrapped in proxies directly.

The cool part: inside templates, Vue automatically unwraps refs, so you can use {{ count }} instead of{{ count.value }}` in your template.

Rule of thumb:

  • Use ref() for single values (primitives).
  • Use reactive for objects and collections.

computed(): Derived, Read-Only State

Sometimes, your data depends on other data. Instead of manually updating values, let Vue handle it with computed().

import { ref, computed } from 'vue'

const price = ref(100)
const quantity = ref(2)

const total = computed(() => price.value * quantity.value)

computed properties are cached and only rerun when their dependencies change—perfect for anything derived from existing state.

watch() and watchEffect(): Responding to Changes

watch() is your go-to for running side effects when reactive data changes—for example, saving data to localStorage or triggering an API call.

import { ref, watch } from 'vue'

const username = ref('')

watch(username, (newValue) => {
  localStorage.setItem('username', newValue)
})

watchEffect works similarly but is even more automatic. It immediately runs and tracks dependencies on its own:

import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  console.log(`Count is now ${count.value}`)
})

If you’re not sure which one to use, start with watchEffect(). It’s simpler for quick reactions to state.

How Vue’s Reactivity Works Under the Hood

Here’s a high-level mental model of how Vue’s reactivity works:

  1. You access a reactive property → Vue tracks it.
  2. You change that property → Vue triggers updates.
  3. Vue efficiently re-renders only what depends on it.

Want to peek behind the curtain? Here’s a simplified reactivity system you can try yourself:

let value
const subscribers = new Set()

function effect(fn) {
  subscribers.add(fn)
  fn()
}

function set(newVal) {
  value = newVal
  subscribers.forEach(fn => fn())
}

// Usage
effect(() => console.log('Value is', value))
set(1) // logs "Value is 1"
set(2) // logs "Value is 2"

That’s basically what Vue does, just much more optimized and feature-rich.

Reactivity in the Composition API
The Composition API is built entirely on top of reactivity. Inside setup(), you can freely mix ref(), reactive(), computed() and watch() to manage state and logic cleanly:

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

const cart = reactive({
  items: [],
})

const total = computed(() =>
  cart.items.reduce((sum, item) => sum + item.price, 0)
)

function addItem(item) {
  cart.items.push(item)
}
</script>

Everything inside setup() stays reactive and automatically updates your template whenever your state changes .

Practical Example—A Reactive Todo List

Here is everything in action:

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

const todos = reactive({
  list: [],
  newTodo: ''
})

const total = computed(() => todos.list.length)

function addTodo() {
  if (!todos.newTodo) return
  todos.list.push(todos.newTodo)
  todos.newTodo = ''
}

watch(
  () => todos.list,
  (newList) => localStorage.setItem('todos', JSON.stringify(newList)),
  { deep: true }
)
</script>

<template>
  <div>
    <input v-model="todos.newTodo" placeholder="Add a todo" />
    <button @click="addTodo">Add</button>
    <p>Total: {{ total }}</p>

    <ul>
      <li v-for="todo in todos.list" :key="todo">{{ todo }}</li>
    </ul>
  </div>
</template>

In this example:

  • reactive() manages our state object
  • computed() handles derived totals
  • And watch() persists the data

Everything is tied together by Vue’s reactivity system. Everything stays perfectly in sync without a single manual DOM update.

Gotchas and Edge Cases

Even though Vue’s reactivity feels seamless, there are a few quirks worth remembering:

  • Destructuring breaks reactivity:
const { count } = reactive({ count: 0 })
count++ // ❌ not reactive anymore

Always access properties directly from the reactive object.

  • Ref vs. reactive confusion: You can’t use .value on a reactive object, only on a ref.
  • Shallow reactivity: If you only need top-level tracking, shallowReactive() or shallowRef() might be better for performance-heavy objects.
  • Reassigning reactive objects: Reassigning a whole reactive object breaks its reference. Mutate its properties instead.

Wrapping Up

Understanding Vue’s reactivity is like unlocking the framework’s secret language. Once you understand how data flows and updates behind the scenes, everything from state management to the Composition API starts to click.

As an add-on, take a look at these best practices for working with reactivity:

  • Prefer computed over watch for derived state.
  • Keep watchers side-effect-free.
  • Avoid unnecessary .value access—templates handle that for you.
  • Be cautious when mixing reactive and non-reactive data.
  • Don’t overuse reactivity—keep it simple and purposeful.

In addition, here are tips for debugging reactivity when things don’t seem to update as expected:

  • Use isReactive() or isRef() to check what’s reactive.
  • Use toRaw() if you need the original, unwrapped object (for logging or external libraries).
  • Vue DevTools can visualize your reactivity graph—extremely helpful for debugging complex state.

If you’ve enjoyed this deep dive, stick around—this article in this Vue Basics series explores how Vue’s lifecycle hooks tie into reactive state and when to tap into them for precise control over your components.


david-adeneye-sq
About the Author

David Adeneye Abiodun

David Adeneye is a software developer and a technical writer passionate about making the web accessible for everyone. When he is not writing code or creating technical content, he spends time researching about how to design and develop good software products.

Related Posts

Comments

Comments are disabled in preview mode.