watch vs watchEffect vs computed in Vue: when to use what?
AI generated
<v/>
{ }
Vue Reactivity · computed · watch · watchEffect · Composition API
watch vs watchEffect
vs computed: when to use what?

computed, watch and watchEffect are the three reactivity primitives of the Vue 3 Composition API, and all three solve different problems. Anyone who mixes them up either writes unnecessary side effects, forgoes caching, or debugs endless watcher cascades that a single computed would have solved.

13 min read computed · watch · watchEffect · immediate · deep · onCleanup Vue 3 · Composition API · TypeScript

1. Understanding the Vue 3 reactivity system

Vue 3 builds its reactivity system on JavaScript's Proxy object. When a reactive value (via ref(), reactive() or shallowRef()) is read inside a reactive context, Vue automatically registers a dependency. When the value changes, Vue notifies all registered dependencies and triggers their recomputation or execution. This mechanism runs transparently in the background, developers do not have to manually declare dependencies or maintain observation lists.

computed, watch and watchEffect are the three main ways of reacting to changes in reactive data. They differ fundamentally in their purpose: computed calculates a new, derived value from reactive sources and caches the result. watch observes explicitly defined reactive sources and runs side effects on change, with access to both the old and the new value. watchEffect performs automatic dependency tracking and runs immediately on first call, like a computed that produces a side effect instead of a return value. Choosing the wrong primitive leads to suboptimal code that either computes too much, caches too little, or produces uncontrolled side effects.

2. computed: derived values with caching

computed in Vue 3 is the right tool for any value that is derived from reactive sources and consumed in a template or in other code. The decisive advantage over a plain function is caching. A computed property is only recalculated when one of its dependencies has changed. If it is referenced multiple times in a template, Vue returns the cached value without re-running the getter function. A plain method called in a template is re-executed on every render pass, regardless of whether the input data has changed.

A common mistake: developers use watch to hold a derived value in a separate ref(). Example: a watcher observes an array and, on change, sets filteredItems.value = items.value.filter(...). That is unnecessary code that also fails to benefit from the caching of computed and runs immediately on every array change, even when the value is not currently being displayed in the template. The equivalent computed only calculates when filteredItems is actually read. For writable derived values, computed offers a getter-setter pair, useful for bidirectional v-model bindings on computed values.


// Comparing computed vs watch for derived values
import { ref, computed, watch } from 'vue'

// WRONG: watch to maintain a derived ref, misuse of watch
const items = ref([{ id: 1, active: true }, { id: 2, active: false }])
const activeItems = ref([])
watch(items, (newItems) => {
  activeItems.value = newItems.filter(i => i.active)
}, { immediate: true, deep: true })
// Problem: executes even when activeItems is not consumed; no caching

// RIGHT: computed, cached, lazy, no side effects
const activeItems2 = computed(() =>
  items.value.filter(i => i.active)
)
// Only recalculated when items.value changes AND activeItems2.value is read

// Writable computed for bidirectional v-model
const firstName = ref('Maria')
const lastName = ref('Muster')

const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value: string) => {
    const [first, ...rest] = value.split(' ')
    firstName.value = first
    lastName.value = rest.join(' ')
  }
})
// <input v-model="fullName" /> reads and writes via computed

3. watch: side effects with control

watch in Vue 3 is the right tool for side effects that react to changes in reactive sources and need access to the old value. These are the cases that computed cannot cover: an API call when an ID changes, syncing state with localStorage, triggering animations, or logging changes for analytics. watch is explicit: the observed sources are defined as the first argument, not discovered through automatic dependency tracking while the callback runs.

The immediate: true option makes the watcher run right away when it is created, useful when the side effect should also apply to the initial value without writing the code twice. The deep: true option deeply observes nested objects and arrays, but be careful: deep-watching large objects is expensive because Vue has to traverse all properties recursively. For reactive() objects, the watcher runs as a deep watcher by default; for ref()-wrapped primitives no deep: true is needed. watch also returns a stop function that can be used to manually deregister the watcher.

4. watchEffect: automatic dependency tracking

watchEffect in Vue 3 combines the immediate execution of immediate: true with automatic dependency tracking, like a computed that produces a side effect instead of a return value. The effect runs immediately on the first call and every reactive value read during that run is registered as a dependency. If any of those dependencies changes, the effect runs again. That makes watchEffect more compact than watch for cases where several reactive sources need to be observed and access to the old value is not required.

The most important difference from watch: watchEffect does not provide an old value. It is not possible to access the previous state of a reactive source inside the callback. In addition, the dependency list is implicit, whatever is read inside the effect is observed. That can lead to surprising side effects when reactive values are read inside the effect code that were not actually meant to act as triggers. In such cases, watch with an explicit source is the clearer choice. watchEffect is particularly well suited for DOM synchronization, logging, and other simple effects where every reactive value read should also act as a trigger.


// watch vs watchEffect: key differences demonstrated
import { ref, watch, watchEffect } from 'vue'

const userId = ref(1)
const section = ref('profile')

// watch: explicit sources, access to old value, NOT immediate by default
watch(userId, async (newId, oldId) => {
  console.log(`User changed from ${oldId} to ${newId}`)
  // oldId is available, not possible with watchEffect
  await fetchUser(newId)
})

// watchEffect: auto-tracks dependencies, immediate, no old value
// Both userId AND section are tracked, any change triggers refetch
watchEffect(async () => {
  // Both refs are read here → both are dependencies
  const data = await fetchUserSection(userId.value, section.value)
  userData.value = data
})
// Equivalent watch would need: watch([userId, section], ...)

// watchEffect with cleanup, for cancellable async operations
watchEffect(async (onCleanup) => {
  const controller = new AbortController()

  // Register cleanup before async call
  onCleanup(() => controller.abort())

  const data = await fetch(`/api/users/${userId.value}`, {
    signal: controller.signal
  }).then(r => r.json())

  userData.value = data
})
// If userId changes before fetch completes → previous request is aborted

5. The key differences in detail

The deepest difference between computed, watch and watchEffect lies in their timing and purpose. computed is lazy: the getter only runs once the computed value is read. If nobody consumes the value, Vue does not compute it even if dependencies change. That is the fundamental performance advantage over a watcher with immediate execution.

watch is lazy by default with regard to its first call (no immediate by default), but the callback is scheduled and run as soon as a source changes, regardless of whether the result is consumed anywhere. watchEffect is immediate and runs the effect synchronously before the first render (or asynchronously with flush: 'post' for DOM access). The flush option controls exactly when the watcher runs relative to Vue's render cycle: pre (before render, the default), post (after render, for DOM access), and sync (synchronous on every change, rarely useful).

6. Cleanup and stopping watchers

Watchers must be cleaned up when their component is destroyed. In the Composition API, watchers created inside setup() or <script setup> are stopped automatically when the component unmounts. That applies to all three primitives: computed, watch and watchEffect. Anyone who creates a watcher outside the component lifecycle, for example in an asynchronous callback after an await, must call the returned stop function manually, because in that case Vue does not know the connection to the component.

For asynchronous operations inside watchEffect and watch, the onCleanup function is the right tool. It is called before the next run of the effect and when the watcher is stopped. The standard pattern for cancellable API calls: create an AbortController, register it in onCleanup, and pass the AbortSignal to the fetch call. If the source changes before the call completes, the AbortController fires and the running request is cancelled. Without this pattern, pending requests pile up and could set state in an unexpected order.

7. API calls with watch and watchEffect

Triggering API calls on changes to reactive values is one of the most common use cases for watch and watchEffect in Vue 3. A product list filter that updates on route param changes, a search field with debounce that loads new results on input, or a detail panel that refills when an ID changes, all of these are side effects that react to reactive changes and therefore belong in watch or watchEffect, not in computed.

For search fields with debouncing, watch with { debounce } from the VueUse library (via watchDebounced) or a manually implemented debounce pattern is the right tool. Plain watch runs the callback immediately on every change, meaning one API call per typed character in a search field. Combining it manually with a debounce wrapper is the correct fix. VueUse offers watchDebounced and watchThrottled as convenient abstractions that encapsulate clean debounce/throttle behavior for watchers, without having to juggle setTimeout and cleanup by hand.


// Practical patterns: watch and watchEffect for API calls
import { ref, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'

const searchQuery = ref('')
const results = ref([])
const isLoading = ref(false)
const error = ref<Error | null>(null)

// Debounced search: wait 300ms after last keystroke before fetching
watchDebounced(
  searchQuery,
  async (query) => {
    if (!query.trim()) {
      results.value = []
      return
    }

    isLoading.value = true
    error.value = null

    try {
      const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      results.value = await response.json()
    } catch (e) {
      error.value = e as Error
      results.value = []
    } finally {
      isLoading.value = false
    }
  },
  { debounce: 300, maxWait: 1000 }
)

// watch with old value: only reload if id actually changed (not on filter change)
const productId = ref(42)
const filterParams = ref({ sortBy: 'price', order: 'asc' })

watch(productId, async (newId, oldId) => {
  // Skip if id hasn't changed, e.g. after route re-navigation to same product
  if (newId === oldId) return
  await loadProduct(newId)
}, { immediate: true })

8. Common pitfalls and how to avoid them

The most common pitfall with computed: side effects inside the getter. computed getters should be pure functions, no writing state, no API calls, no DOM manipulation. Vue may run getters multiple times, in server-side rendering environments they can run synchronously, and asynchronous computeds are not supported. Anyone who needs an asynchronous value in a computed-like structure can use asyncComputed from VueUse or build a custom composable with ref and watch/watchEffect.

With watch, the most common mistake is accidentally observing the value instead of the reference. watch(myRef.value, ...) observes the value at the time of the call, not the ref reactively. Correct is watch(myRef, ...) or watch(() => myObj.property, ...) for properties on reactive objects. With object sources: watch(() => ({ ...myReactive })) using a getter that returns a new object triggers the callback on every property change, because a new reference is created every time. That is often not intended, deep: true on the direct source is the correct solution in that case.

9. Decision table: computed, watch or watchEffect?

The choice between computed, watch and watchEffect follows a clear pattern that can be summarized in a table:

Criterion computed watch watchEffect
Purpose Derived value Side effect on change Immediate effect, auto-tracking
Returns Reactive value Stop function Stop function
Old value Not available Available as 2nd param Not available
Runs immediately? Lazy (only when read) No (opt-in: immediate) Yes, always
Dependency tracking Automatic Explicit (controlled) Automatic
Async? Not supported Yes, with onCleanup Yes, with onCleanup

The decision rule in one sentence: do I need a value from reactive sources? Then computed. Do I need a side effect and the old value or explicit source control? Then watch. Do I need an immediate side effect across several reactive sources, without access to old values? Then watchEffect. These three questions resolve 95% of all decision situations in day-to-day Vue 3 development.

Mironsoft

Vue.js development, Composition API and reactivity architecture

Want to get Vue 3 reactivity right?

We analyze existing Vue applications for reactivity antipatterns, unnecessary watchers and missing computed optimizations, and implement clean, performant solutions.

Code review

Analysis of watch/watchEffect misuse, missing computeds and cleanup issues

Performance audit

Identifying unnecessary re-renders caused by wrongly chosen reactivity primitives

Composable design

Clean composables with correct cleanup, dependency management and TypeScript types

10. Summary

computed, watch and watchEffect are the three reactivity primitives of the Vue 3 Composition API, and they solve different problems. computed produces cached, derived values from reactive sources, always when a value is needed, no side effect is required, and performance benefits from caching. watch observes explicitly defined sources, provides the old and new value, and is suited for controlled side effects such as API calls, localStorage sync, and analytics. watchEffect runs immediate, automatically tracked effects, without access to old values, and is the most compact choice for synchronous synchronization tasks.

The most common mistakes: side effects inside computed getters, using watch to hold a derived value instead of computed, missing cleanup for asynchronous operations, and deep-watching large objects without necessity. Anyone who consistently applies the three primitives according to their purpose writes reactive Vue code that is performant, testable, and understandable for the whole team.

watch vs watchEffect vs computed: the essentials at a glance

computed

Derived, cached value from reactive sources. Lazy, only computed when read. No side effects, no async. First choice for template values.

watch

Explicit source specification, access to old value. Ideal for API calls, localStorage sync, and any side effect where the before/after comparison matters.

watchEffect

Immediate, automatic dependency tracking. Ideal for synchronous synchronization tasks with multiple sources. No access to the old value.

Cleanup

onCleanup in watch/watchEffect for cancellable async operations, the AbortController pattern prevents race conditions and stacked requests.

11. FAQ: watch vs watchEffect vs computed in Vue 3

1computed vs watch: main difference?
computed produces cached values, lazy, only computed when read. watch runs side effects and provides the old and new value. computed for values, watch for actions.
2watchEffect instead of watch: when?
When the effect should run immediately, several sources should be tracked automatically, and access to the old value is not needed. watch for explicit source control and before/after comparison.
3Can computed be asynchronous?
No. Getters must be synchronous. For async derived values: combine asyncComputed from VueUse or ref + watchEffect.
4What does onCleanup do?
Registers a function that is called before the next watcher execution. AbortController pattern: register before the fetch, prevents race conditions and stacked requests.
5watch to hold a derived value?
Antipattern. computed is lazy and cached, only computed when read. watch runs on every change regardless of whether the value is consumed.
6Observe a property on a reactive object?
watch(() => myReactive.property, callback), with a getter function. Directly watch(myReactive.property, ...) observes the value at the time of the call, not reactively.
7Stop watchers manually?
Inside setup(), watchers stop automatically with the component. In async callbacks or outside: call const stop = watch(...); stop() manually.
8flush: 'pre' vs 'post'?
pre (default): before the DOM update. post: after the DOM update, for template ref access to updated DOM. sync: synchronous, rarely useful.
9Debounce a watch callback?
watchDebounced from VueUse with { debounce: 300 }. Manually: a debounced wrapper in the callback and clear the timeout in onCleanup.
10Multiple sources with one watch?
watch([refA, refB], ([newA, newB], [oldA, oldB]) => ...), arrays as sources and in the callback. Alternatively watchEffect for automatic tracking of all reactive values read.