Reactivity Best Practices in Vue

Reactivity Best Practices in Vue

Learn Vue 3 reactivity best practices, including ref vs reactive, computed caching, watch vs watchEffect, common pitfalls, and performance tips.

Reza Baar

Reza Baar

March 19, 2026

Vue's reactivity system is one of its biggest strengths, but it's also where most bugs hide. If you've been writing Vue 3 with the Composition API for a while, you've probably hit a few of these walls already. In this post I have collected a group of patterns, pitfalls, and performance tricks that will save you debugging time.

By the end of this post, you'll know:

  • When to reach for ref vs reactive (and why one is almost always the better default)
  • How computed caching works and when it recalculates
  • The difference between watch and watchEffect
  • The most common reactivity pitfalls and how to avoid them
  • Performance patterns that matter at scale

ref vs reactive

Both create reactive state. But ref is the safer default. Here's why.

reactive only works with objects. It returns a proxy of the original object, which means reassigning the variable breaks reactivity entirely. The proxy is lost, and Vue stops tracking changes.

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

let state = reactive({ count: 0 })

// This breaks reactivity. `state` is now a plain object.
state = { count: 1 }
</script>

    

ref wraps any value (primitives, objects, arrays) and keeps reactivity through .value. You can reassign .value freely without losing the reactive connection.

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

const state = ref({ count: 0 })

// This is fine. Reactivity is preserved.
state.value = { count: 1 }
</script>

    

Quick rules:

  • Use ref for everything by default
  • reactive is fine for local, complex objects you'll never reassign
  • If you're passing state around between composables, ref is almost always what you want
  • Declare refs with const. The ref itself shouldn't be reassigned, only .value

computed

computed is for derived state. If a value can be calculated from other reactive values, it should be a computed, not a ref you manually keep in sync.

The key behavior: computed values are cached. Vue only recalculates them when one of their reactive dependencies changes. If nothing changed, accessing the computed returns the cached result instantly.

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

const items = ref([
  { name: 'Shirt', price: 25 },
  { name: 'Pants', price: 40 },
])

// Only recalculates when `items` changes
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
</script>

    

Things to keep in mind:

  • Keep computed getters pure. No side effects, no API calls, no mutations inside them
  • Writable computed (get/ set) exists but should be rare. If you find yourself reaching for it often, rethink your data flow
  • A computed that depends on another computed is totally fine. Vue handles the dependency chain

watch vs watchEffect

These two overlap but serve different purposes. Picking the right one makes your code clearer.

watch

Watches specific sources you explicitly pass. Gives you both the old and new value. Does not run immediately by default.

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

const searchQuery = ref('')

watch(searchQuery, (newVal, oldVal) => {
  console.log(`Changed from "${oldVal}" to "${newVal}"`)
  fetchResults(newVal)
})
</script>

    

Use watch when:

  • You need the previous value
  • You want to react to a specific source, not everything
  • You want control over when it first runs (immediate: true to run on mount)
  • You want to debounce or conditionally skip the callback

watchEffect

Automatically tracks every reactive dependency used inside its callback. Runs immediately on creation.

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

const userId = ref(1)
const includeDetails = ref(false)

watchEffect(() => {
  // Automatically re-runs when `userId` OR `includeDetails` changes
  fetchUser(userId.value, includeDetails.value)
})
</script>

    

Use watchEffect when:

  • You want automatic dependency tracking (no need to list sources)
  • The "run immediately" behavior is what you want
  • You don't need old values

Beware: watchEffect tracks dependencies dynamically. If a dependency is behind an if statement and that branch doesn't execute on the first run, it won't be tracked until the branch runs.

      watchEffect(() => {
  // If `enabled` starts as false, `query` is NOT tracked yet
  if (enabled.value) {
    fetch(`/api/search?q=${query.value}`)
  }
})

    

Pitfalls and Common Mistakes

Destructuring reactive objects kills reactivity

This is probably the most common mistake. When you destructure a reactive object, the resulting variables are plain values with no reactive connection.

      const state = reactive({ count: 0, name: 'Vue' })

// These are just plain variables now. Not reactive.
const { count, name } = state

    

Fix? Use toRefs to convert each property into a ref that stays connected.

      import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = toRefs(state)

// Now `count` and `name` are refs linked to the original reactive object
count.value++ // updates state.count

    

Pro tip? Same thing applies to Pinia stores. Use storeToRefs when destructuring store state.

      import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const store = useUserStore()
const { username, email } = storeToRefs(store)

    

Forgetting .value in script

In <template>, Vue auto-unwraps refs. In <script>, you need .value. This is straightforward but catches everyone at least once, especially when switching between template and script context.

Deep vs shallow reactivity

By default, ref and reactive create deep reactive proxies. Every nested property is tracked. This is usually what you want, but for large objects it can be expensive.

      import { shallowRef } from 'vue'

// Only `.value` assignment is tracked, not nested changes
const bigData = shallowRef({ nested: { deep: { value: 1 } } })

// This does NOT trigger updates
bigData.value.nested.deep.value = 2

// This does
bigData.value = { nested: { deep: { value: 2 } } }

    

Use shallowRef when you replace the whole object (like data from an API response) and don't need to track individual nested mutations.

Performance

Prefer computed over watchers for derived state

A computed is lazily evaluated and cached. A watch that writes to another ref to keep derived state in sync is doing extra work and creating unnecessary reactive overhead.

      // Don't do this
const items = ref([])
const count = ref(0)

watch(items, (val) => {
  count.value = val.length
})

// Do this instead
const count = computed(() => items.value.length)

    

Use shallowRef for large datasets

If you're dealing with big arrays or deeply nested objects that get replaced wholesale (paginated API data, table rows, chart datasets), shallowRef avoids the cost of deep proxy creation.

      const rows = shallowRef([])

async function loadPage(page) {
  const data = await fetchRows(page)
  rows.value = data // triggers reactivity
}

    

If you ever need to force a re-render after mutating a shallowRef's internals, use triggerRef:

      import { shallowRef, triggerRef } from 'vue'

const list = shallowRef([1, 2, 3])
list.value.push(4) // won't trigger updates
triggerRef(list)    // now it will

    

Template-level optimizations

  • v-once renders an element once and never updates it. Good for truly static content inside a dynamic component.
  • v-memo caches a template subtree and only re-renders when its dependency array changes. Useful in large v-for lists.
      <template>
  <div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
    <p>{{ item.name }}</p>
    <span>{{ item.selected ? 'Yes' : 'No' }}</span>
  </div>
</template>

    

Keep reactive state flat

Deeply nested reactive objects are harder to work with and more expensive to track. When possible, normalize your state. Instead of nesting objects three levels deep, use flat structures with IDs as references.

Wrapping Up

The reactivity system in Vue does a lot of heavy lifting for you, but knowing where the edges are makes a real difference. Start with ref, reach for computed before watch, and be intentional about deep vs shallow reactivity. Most performance issues and mysterious bugs in Vue apps trace back to one of the patterns covered here.

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Looking for Certified Developers?

We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.

Let us help you get the resources you need.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)