Computed, methods and watchers in Vue.js
Computed, methods and watchers are Vue's three tools for reactive data: computed caches derived values and recalculates only when dependencies change, methods run on every call with no caching, and watchers fire side effects when a specific source mutates.
Theory
TL;DR
- Computed = cached spreadsheet formula: recalculates only when inputs change
- Methods = calculator button: runs fresh on every call, no memory between calls
- Watchers = observer: fires a callback when a specific ref or reactive object changes
- Need a display value? Computed. Handling a user event with params? Method. Fetching on change? Watcher.
- Computed is sync-only; watchers support async (API calls, localStorage, debounce)
Quick example
<template>
<button @click="count++">Clicks: {{ count }}</button>
<p>Double (computed): {{ doubleCount }}</p>
<p>Double (method): {{ doubleCountMethod() }}</p>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const count = ref(0)
const doubleCount = computed(() => {
console.log('Computed runs') // Fires once per count change
return count.value * 2
})
const doubleCountMethod = () => {
console.log('Method runs') // Fires on every render cycle
return count.value * 2
}
watch(count, (newVal) => {
console.log('Watcher fires:', newVal) // Fires on each count change
})
</script>Click the button and watch the console: "Computed runs" appears once per click, "Method runs" appears on every render cycle, and the watcher fires each time count changes.
Key difference
Computed properties track reactive dependencies during their first run and cache the result. Vue's reactivity system (Proxy in Vue 3) records which refs were accessed, then invalidates the cache only when those refs change. Methods are plain functions with no tracking whatsoever. Watchers use an explicit source and run callbacks asynchronously through Vue's scheduler, which makes them the right fit for side effects like API calls, DOM updates, or writing to localStorage.
When to use
- Displaying derived data (fullName from firstName + lastName): computed
- Handling user events with parameters (form submit, button click): method
- Reacting to a data change with an API fetch or localStorage write: watcher
- Reading the same value more than once per render: computed, not method
- Watching multiple reactive sources in one callback: watcher with an array source
- One-off calculation that needs a parameter: method
Comparison table
| Feature | Computed | Methods | Watchers |
|---|---|---|---|
| Caching | Yes, dependency-based | No | No |
| Returns value | Always | Optional | No |
| Trigger | Getter access + dep change | Every call | Source change |
| Async support | No (sync only) | Yes | Yes |
| Side effects | Not recommended | Fine | Designed for them |
| Best for | Template display values | Event handlers, parameterized calls | API fetches, debounce, localStorage |
How Vue handles this internally
Vue 3 wraps refs and reactive objects with Proxy. When a computed getter runs for the first time, Vue activates an Effect and records every reactive value accessed via track(). On dependency change, the Effect is marked stale and the getter re-runs on next access, not immediately. That is why computed is lazy and cached. Methods bypass this entirely. Watchers use the same Effect mechanism but with an explicit source and flush asynchronously through queueScheduler, so they run after Vue finishes the current DOM update cycle.
Common mistakes
1. Method for repeated display values
<!-- getFullName() fires on every render -->
<template>
<p>{{ getFullName() }}</p>
<p>{{ getFullName() }}</p>
</template>
<!-- computed fires once per dependency change -->
<template>
<p>{{ fullName }}</p>
<p>{{ fullName }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('Jane')
const lastName = ref('Smith')
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
</script>In a list of 1000 items this difference is measurable. Use computed when the same derived value appears multiple times in the template.
2. Async inside computed
<script setup>
import { ref, computed, watchEffect } from 'vue'
const userId = ref(1)
const user = ref(null)
// Wrong: computed must be synchronous
const userName = computed(async () => {
user.value = await fetchUser(userId.value) // Returns a Promise, not the name
return user.value.name
})
// Correct: watchEffect for async
watchEffect(async () => {
if (userId.value) {
user.value = await fetchUser(userId.value)
}
})
</script>Computed returns a Promise object, not the resolved value. Vue does not await it. This is one of the most common gotchas in Nuxt.js auth modules.
3. Side effects inside computed
<script setup>
// Wrong: side effect fires on every getter access
const bad = computed(() => {
apiCall() // If template reads this 5 times, apiCall fires 5 times
return count.value * 2
})
// Correct
watch(count, () => {
apiCall()
})
</script>4. Watcher on a non-reactive value
<script setup>
let plainVar = 'hello'
// Wrong: plainVar is not reactive, this never triggers
watch(plainVar, (newVal) => console.log(newVal))
// Correct: wrap in ref, or pass a getter function
const reactiveVar = ref('hello')
watch(reactiveVar, (newVal) => console.log(newVal))
</script>5. Deep watcher on large objects
// Wrong: recursively scans the entire object on every mutation
watch(items, cb, { deep: true })
// Better: watch only what you actually need
watch(() => items.value.length, cb){ deep: true } on a 500-item array triggers a full recursive scan on every nested change. Narrow it down where possible.
Real-world usage
- Pinia/Vuex stores: getters are computed properties (filtered todos, cart totals)
- Vue Storefront:
totalcomputed from the cart items array, cached across re-renders - Nuxt.js: watchers on route params to trigger data fetches on navigation
- Element Plus: computed for dynamic form validation messages
- Vuetify: methods for parameterized button handlers
Follow-up questions
Q: Why can't computed handle async operations?
A: Computed requires a synchronous return value. Returning a Promise gives Vue a Promise object, not the resolved data. Use watchEffect or watch with an async callback for any async logic.
Q: How does Vue detect which dependencies a computed property has?
A: During the getter's first run, Vue activates an Effect and calls track() on every reactive Proxy that is accessed. Those become the dependencies. When any of them change, trigger() marks the computed as stale.
Q: What is the difference between watch and watchEffect?
A: watchEffect runs immediately, auto-collects dependencies from its body, and re-runs when any of them change. watch is explicit: you declare the source, it is lazy by default, and the callback receives both old and new values.
Q: When does a computed property invalidate for object dependencies?
A: By default Vue tracks only the shallow level: the object reference itself. Mutating a nested property without replacing the ref does not invalidate the computed. Use reactive or restructure so the reference itself changes.
Q: (Senior) If you implement a custom memoized function with a plain closure, what does it miss compared to Vue's computed?
A: The scheduler. Vue's computed batches invalidations and delays re-runs to the next tick, avoiding redundant recalculations when multiple deps change in the same microtask. A plain closure re-runs immediately on every change with no batching.
Examples
Cart total with computed
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ name: 'Shirt', price: 30, qty: 2 },
{ name: 'Pants', price: 60, qty: 1 }
])
const total = computed(() => {
// Recalculates only when items array or its contents change
return items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
})
</script>
<template>
<ul>
<li v-for="item in items" :key="item.name">
{{ item.name }} x{{ item.qty }} = ${{ item.price * item.qty }}
</li>
</ul>
<p>Total: ${{ total }}</p>
</template>total reads once per render cycle no matter how many times the template references it. Edit any qty and Vue recomputes, but only then. This pattern appears in Vue Storefront and similar e-commerce PWAs.
Search with debounced watcher
<script setup>
import { ref, watch } from 'vue'
const query = ref('')
const results = ref([])
let timer = null
watch(query, (newVal) => {
clearTimeout(timer)
timer = setTimeout(async () => {
if (newVal.trim()) {
results.value = await searchAPI(newVal)
} else {
results.value = []
}
}, 300)
})
async function searchAPI(q) {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
return res.json()
}
</script>
<template>
<input v-model="query" placeholder="Type to search..." />
<ul>
<li v-for="r in results" :key="r.id">{{ r.title }}</li>
</ul>
</template>This is the pattern watchers are built for: an async side effect that should debounce, not block the render, and not return a value to the template. Computed would be the wrong tool here.
Writable computed for two-way binding
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('Jane')
const lastName = ref('Smith')
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue) {
const parts = newValue.split(' ')
firstName.value = parts[0] ?? ''
lastName.value = parts[1] ?? ''
}
})
</script>
<template>
<input v-model="fullName" />
<p>First: {{ firstName }}, Last: {{ lastName }}</p>
</template>Writable computed is less common but useful when a parent component works with a combined value while the underlying state stays split. I've seen this in form libraries that expose a single v-model binding but store address components separately.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.