Hi — I’m writing this as if I’m at my favorite coffee shop debugging a stubborn reactivity bug while humming a bad 90s pop song. Over the years (and a few production incidents later), I learned the hard way when to use a method, when to rely on a computed property, and when you absolutely should reach for a watch. This guide bundles that experience into concrete steps, working examples, and best practices to save you time (and grey hairs).
Table of contents [Show]
- Why this topic matters
- Quick TL;DR (for impatient developers)
- How Vue reactivity works (brief, so we can get practical)
- Real-world scenario we'll use throughout this article
- Section 1 — Methods in Vue.js (what they are and when to use them)
- Section 2 — Computed properties in Vue.js (cached, lazy, perfect for derived state)
- Section 3 — Watchers (when you need to perform side effects)
- Section 4 — Composition API equivalents (because Vue 3)
- Section 5 — Side-by-side examples illustrating differences
- Section 6 — Advanced watcher patterns
- Section 7 — Performance considerations and anti-patterns
- Section 8 — Practical step-by-step guide: Build the searchable user list
- Section 9 — Common mistakes and how to avoid them
- Section 10 — Debugging tips
- Section 11 — When to use watchEffect or watch (Composition API nuance)
- Section 12 — Comparison table (Methods vs Computed vs Watchers)
- Section 13 — Checklist: which should I use?
- Section 14 — Personal notes from the trenches (what I wish I knew earlier)
- Section 15 — Best practices & rules of thumb
- Section 16 — Advanced example: computed with setter + watcher for persistence
- Section 17 — FAQ
- Section 18 — Summary and final recommendations
- Section 19 — Extra: migration notes (Vue 2 -> Vue 3)
- Closing thoughts (and a tiny challenge)
Why this topic matters
Vue’s reactivity model is beautiful — but deceptively simple. Beginners often confuse methods, computed properties, and watchers. The result? Unnecessary re-computations, multiple network calls, and components that behave like unpredictable ghosts. Understanding the difference improves performance, maintainability, and developer happiness.
Quick TL;DR (for impatient developers)
- Use
methods()for event handlers and operations you call imperatively — e.g., form submissions or button clicks. - Use
computed()for derived state that depends on reactive sources and should be cached — e.g., filtered lists, formatted strings. - Use
watch()for side effects when reactive data changes — e.g., API calls, manual DOM updates, or when you need to react to changes over time.
Pro tip: If you're computing a value for display and it depends on reactive state — prefer
computed. If you're performing an asynchronous side-effect because a value changed — use watch.How Vue reactivity works (brief, so we can get practical)
At a high level, Vue tracks reactive sources (like ref(), reactive(), or data properties). When those sources change, Vue re-runs the render, and any computed properties that depend on them will update (and be cached until needed again). Watchers observe changes and let you run arbitrary code — including async operations.
Keywords to remember
Reactivity, caching, dependency tracking, side-effect, derived state, invalidation.
Real-world scenario we'll use throughout this article
Imagine a small component used in many apps: a searchable, paginated list of "users" that:
- Accepts a free-text search term.
- Filters a local list of users (derived state).
- Makes an API call when the search term changes (side effect) but with debounce.
- Shows computed statistics like the number of visible users and a dynamic headline.
We will implement this with Options API examples, then re-show the same functionality with Composition API so you can see both patterns.
Section 1 — Methods in Vue.js (what they are and when to use them)
What is a method?
A method is a function defined on your component instance and called imperatively — typically from templates via event handlers (@click, @submit). Methods are not cached. Every time the template re-renders, calling a method in the template will execute the method again.
When to use methods
- Event handlers: button clicks, form submissions.
- Imperative actions that should run every time they're invoked.
- Functions that mutate state or perform side effects directly.
Example — Counter with method
// Options API example: Methods for events and actions
export default {
name: 'CounterWithMethod',
data() {
return { count: 0 }
},
methods: {
increment() {
// called imperatively on @click
this.count++
},
reset() {
this.count = 0
}
}
}
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
<button @click="reset">Reset</button>
</div>
</template>Note: If you place increment() directly inside the template as {{ increment() }}, the method will execute on every render — which is rarely what you want. Use methods for events, or call them explicitly, not as template-derived values that should be cached.
Section 2 — Computed properties in Vue.js (cached, lazy, perfect for derived state)
What is a computed property?
A computed property is a function that Vue treats as a reactive dependency. Vue tracks which reactive sources the computed reading uses, caches the result, and re-evaluates only when its dependencies change. This makes computed ideal for expensive or frequently used derived values.
When to use computed
- Formatting or deriving display-friendly values (e.g.,
fullNamefromfirstName+lastName). - Filtering or sorting arrays for display (cached until source changes).
- Computed getters + setters for two-way derived state.
Example — Computed vs Method (filtering a list)
This example illustrates the difference: using a method in the template will re-run every render; using a computed property caches the filtered list until dependencies change.
// Options API - computed property example
export default {
name: 'UserFilter',
data() {
return {
search: '',
users: [
{ id:1, name: 'Alice' },
{ id:2, name: 'Bob' },
{ id:3, name: 'Carla' }
]
}
},
computed: {
// cached until `search` or `users` changes
filteredUsers() {
console.log('computed: filtering users') // runs only when dependencies change
const q = this.search.trim().toLowerCase()
if (!q) return this.users
return this.users.filter(u => u.name.toLowerCase().includes(q))
}
},
methods: {
// called on demand (not cached)
filterUsersMethod() {
console.log('method: filtering users') // runs whenever called
const q = this.search.trim().toLowerCase()
if (!q) return this.users
return this.users.filter(u => u.name.toLowerCase().includes(q))
}
}
}
<template>
<div>
<input v-model="search" placeholder="Search users" />
<h3>Using computed (recommended)</h3>
<ul>
<li v-for="u in filteredUsers" :key="u.id">{{ u.name }}</li>
</ul>
<h3>Using method (not cached)</h3>
<ul>
<li v-for="u in filterUsersMethod()" :key="u.id">{{ u.name }}</li>
</ul>
</div>
</template>Observation: Look at the console logs. Typing into the input will trigger the computed filter only when search changes (and only once per change), whereas the method may be called multiple times during a render cycle.
Computed properties with getters and setters
You can define computed properties with setters to create derived two-way bindings. This is handy when you need to present a transformed value but allow editing.
// computed with getter/setter
computed: {
fullName: {
get() {
return `${this.firstName} ${this.lastName}`
},
set(value) {
const [first, ...rest] = value.split(' ')
this.firstName = first
this.lastName = rest.join(' ')
}
}
}Use this in template as v-model="fullName" and Vue will call the setter when the input changes.
Section 3 — Watchers (when you need to perform side effects)
What is a watch?
A watcher observes reactive sources and runs a callback when those sources change. Watchers are meant for side-effectful code: network requests, manual DOM manipulation, logging, or coordinating multiple pieces of state outside the render process.
When to use watch
- Triggering an API call when a search term changes (with debounce).
- Observing deeply nested objects (use
deep: true). - Performing expensive side effects that shouldn't be executed during render.
- Reacting to route changes (
$route).
This example shows a simple pattern: watch the search term, debounce it, and then call fetchUsers().
export default {
data() {
return {
search: '',
results: [],
loading: false,
_searchTimeout: null
}
},
watch: {
// watch search for API calls — not for derived state
search(newVal, oldVal) {
// debounce: clear previous timer
clearTimeout(this._searchTimeout)
// set a new timer (300ms)
this._searchTimeout = setTimeout(() => {
if (!newVal) {
this.results = []
return
}
this.performSearch(newVal)
}, 300)
}
},
methods: {
async performSearch(query) {
this.loading = true
try {
const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`)
this.results = await res.json()
} catch (e) {
console.error(e)
} finally {
this.loading = false
}
}
}
}This pattern ensures the API isn't called on every keystroke — only after the user pauses typing.
immediate: true— call the watcher callback immediately with the current value.deep: true— watch nested properties inside an object.
// Usage with options
watch: {
someNestedObject: {
handler(newVal) { /* ... */ },
deep: true,
immediate: false
}
}Section 4 — Composition API equivalents (because Vue 3)
Many new projects use the Composition API. The same principles apply: methods are ordinary functions, computed() is used for cached derived state, and watch() is used for side effects.
Composition API — Example: filtered list + debounced API call
// Composition API example
import { ref, computed, watch } from 'vue'
export default {
setup() {
const search = ref('')
const users = ref([
{ id:1, name: 'Alice' },
{ id:2, name: 'Bob' },
{ id:3, name: 'Carla' }
])
const results = ref([])
const loading = ref(false)
let timeoutId = null
// computed for filtering local list (cached)
const filteredUsers = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return users.value
return users.value.filter(u => u.name.toLowerCase().includes(q))
})
// watcher for API calls (side effect)
watch(search, (newVal) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(async () => {
if (!newVal) {
results.value = []
return
}
loading.value = true
try {
const resp = await fetch(`/api/users?q=${encodeURIComponent(newVal)}`)
results.value = await resp.json()
} finally {
loading.value = false
}
}, 300)
})
// method = regular function
function addUser(user) {
users.value.push(user)
}
return { search, filteredUsers, results, loading, addUser }
}
}
Example: formatting a price — computed vs method
// Options API
data() { return { priceCents: 12345 } },
computed: {
// computed caches formattedPrice until priceCents changes
formattedPrice() {
console.log('computing formattedPrice')
return (this.priceCents / 100).toLocaleString(undefined, { style: 'currency', currency: 'USD' })
}
},
methods: {
// method recomputes every time it is called
formatPriceMethod() {
console.log('formatting price via method')
return (this.priceCents / 100).toLocaleString(undefined, { style: 'currency', currency: 'USD' })
}
}In a template: {{ formattedPrice }} is better than {{ formatPriceMethod() }} because the computed version only recalculates when priceCents changes.
Example: side effect — never use computed for async fetch
Wrong: putting async code in a computed property is an anti-pattern. Computed should be pure and synchronous. Use watch or watchEffect for async side effects.
// WRONG - do not do this
computed: {
usersFromApi() {
// Avoid async work here — computed expects a synchronous return value
fetch('/api/users') // <-- bad idea
.then(r => r.json())
.then(data => this.users = data)
return [] // computed can't properly represent async state
}
}Right: use watch or call an async method inside lifecycle hooks.
Section 6 — Advanced watcher patterns
Watching multiple sources
You can watch an array of refs or computed values and react when any of them change.
import { watch } from 'vue'
watch([firstNameRef, lastNameRef], ([newFirst, newLast], [oldFirst, oldLast]) => {
console.log('First or last changed', newFirst, newLast)
})Deep watchers
When observing nested objects, use deep: true in Options API or provide an object to watch() in Composition API:
// Options API
watch: {
userProfile: {
handler(newVal) { /* handle deep changes */ },
deep: true
}
}Immediate watchers
If you want the watcher to run immediately on mount with the current value, specify immediate: true. This pattern is useful for initialization that depends on reactive data.
watch(search, (val) => { /* ... */ }, { immediate: true })Section 7 — Performance considerations and anti-patterns
Prefer computed for heavy calculations
Computed properties cache results. If a derived value is expensive (sorting a big array, complex math), computed saves CPU by only recalculating when dependencies change.
Avoid putting side effects in computed
Computed should be pure and synchronous. Putting network calls or mutations inside computed leads to unpredictable behavior and harder-to-test code.
Minimize watchers in large apps
Overusing watchers as a way to glue state together can lead to fragile code. Use them when you need side effects; prefer composition and derived state for predictable logic.
Template-bound methods can be called multiple times
If you bind a method in a template (e.g., {{ expensiveComputation() }}), it may run multiple times per render. Move such calculations into computed properties.
Section 8 — Practical step-by-step guide: Build the searchable user list
Let's implement the earlier scenario end-to-end. I'll show both Options API and Composition API, step-by-step.
Step 0 — Project scaffolding (Vite + Vue 3)
// Terminal commands (step-by-step)
npm init vite@latest vue-search -- --template vue
cd vue-search
npm install
npm run dev
Open src/components and create UserSearch.vue.
<!-- src/components/UserSearch.vue -->
<template>
<div>
<h2>User Search (Options API)</h2>
<input v-model="search" placeholder="Search users..." />
<div >Computed results are cached; watcher triggers API requests.</div>
<h3>Local filtered users (computed)</h3>
<ul>
<li v-for="u in filteredUsers" :key="u.id">{{ u.name }}</li>
</ul>
<h3>Remote results (via watcher)</h3>
<div v-if="loading">Loading...</div>
<ul>
<li v-for="r in results" :key="r.id">{{ r.name }}</li>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
search: '',
users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Carla' },
{ id: 4, name: 'David' }
],
results: [],
loading: false,
_timer: null
}
},
computed: {
filteredUsers() {
const q = this.search.trim().toLowerCase()
if (!q) return this.users
return this.users.filter(u => u.name.toLowerCase().includes(q))
}
},
watch: {
search(newVal) {
clearTimeout(this._timer)
this._timer = setTimeout(() => {
if (!newVal) {
this.results = []
return
}
this.fetchRemoteUsers(newVal)
}, 350)
}
},
methods: {
async fetchRemoteUsers(q) {
this.loading = true
try {
// Simulated fetch — replace with real API
await new Promise(r => setTimeout(r, 400))
this.results = this.users.filter(u => u.name.toLowerCase().includes(q.toLowerCase()))
} finally {
this.loading = false
}
}
}
}
</script>Step 2 — Composition API implementation
<!-- src/components/UserSearchComposition.vue -->
<template>
<div>
<h2>User Search (Composition API)</h2>
<input v-model="search" placeholder="Search users..." />
<h3>Local filtered users (computed)</h3>
<ul>
<li v-for="u in filteredUsers" :key="u.id">{{ u.name }}</li>
</ul>
<h3>Remote results (via watch)</h3>
<div v-if="loading">Loading...</div>
<ul><li v-for="r in results" :key="r.id">{{ r.name }}</li></ul>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const search = ref('')
const users = ref([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Carla' },
{ id: 4, name: 'David' }
])
const results = ref([])
const loading = ref(false)
let timer = null
const filteredUsers = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return users.value
return users.value.filter(u => u.name.toLowerCase().includes(q))
})
watch(search, (val) => {
clearTimeout(timer)
timer = setTimeout(async () => {
if (!val) {
results.value = []
return
}
loading.value = true
try {
await new Promise(r => setTimeout(r, 400))
results.value = users.value.filter(u => u.name.toLowerCase().includes(val.toLowerCase()))
} finally {
loading.value = false
}
}, 350)
})
</script>Step 3 — Test it locally
- Run
npm run devand visit the dev server. - Type into the search box. Observe the computed filtered local results (instant) and the debounced remote results (after pause).
- Open the browser console to confirm computed functions run only when dependencies change.
Section 9 — Common mistakes and how to avoid them
Mistake: using computed for side effects
Fix: Move the side-effect into a watch or explicit method and call it at the right time.
Mistake: overusing watchers as glue between components
Fix: Prefer a single source of truth via Vuex/Pinia or shared composables. Use watchers sparingly for external effects.
Mistake: binding expensive methods directly in templates
Fix: Use computed properties for expensive derived values. Methods are fine for event handlers.
Mistake: forgetting to clear timers in watchers
Fix: Always clear setTimeout or setInterval timers in watchers, and on unmount if necessary (use onUnmounted in Composition API).
Section 10 — Debugging tips
- Use Vue Devtools to inspect reactive state and computed properties.
- Console-log inside computed methods and watchers to observe when they run.
- Profile your app to find expensive re-renders that can be cached via computed.
Section 11 — When to use watchEffect or watch (Composition API nuance)
watchEffect runs a function immediately, tracks its dependencies, and reruns when they change — useful for reactive side effects that reference multiple reactive sources implicitly. watch gives you explicit control over which source(s) to observe and provides old/new values to the callback.
import { watchEffect, ref } from 'vue'
const count = ref(0)
watchEffect(() => {
console.log('count is', count.value)
})Use watchEffect for simple reactive side effects; use watch when you need the old/new values or more control (debounce, immediate, deep).
Section 12 — Comparison table (Methods vs Computed vs Watchers)
| Concept | Primary purpose | Caching | Side effects | Typical use cases |
|---|---|---|---|---|
| Methods | Imperative actions, event handlers | No | Allowed | Button clicks, form submit, imperative logic |
| Computed | Derived, display-only state | Yes (cached until dependencies change) | Should be pure (avoid side effects) | Formatting, filtering, combining values for display |
| Watchers | React to changes and perform side effects | No (runs callback when observed values change) | Yes (designed for this) | API calls on change, deep observation, coordinating values |
Section 13 — Checklist: which should I use?
- If you need a derived value for display — computed.
- If you need an action triggered by user input — method.
- If you need to run side effects because a reactive value changed — watch.
- If you need to run a reactive effect that references multiple reactive values automatically — consider watchEffect.
Section 14 — Personal notes from the trenches (what I wish I knew earlier)
I'll be honest: early on I used watchers everywhere. I had a component that fetched a list from the server every time an unrelated property changed — because a careless watcher listened to a reactive object without deep awareness and slapped an API call inside. That incident taught me: watchers are powerful and dangerous. I now treat them like a kitchen knife — incredibly useful when used correctly, but don't wave them around carelessly.
Another anecdote: I once used a computed property to return a Promise (async). Vue's devtools showed a value, but the UI was inconsistent and I had race conditions. The fix was simple: computed must be synchronous; move async code to a watcher or method and manage state explicitly (loading, error, data).
Section 15 — Best practices & rules of thumb
- One responsibility per construct: computed = derive state; methods = do things; watch = do side effects.
- Keep computed pure: no mutations, no API calls.
- Use watchers sparingly: prefer computed / composables for predictable logic.
- Cache expensive results: computed is your friend.
- Debounce in watchers: when calling APIs on user input, always debounce.
- For global state: use Pinia / Vuex and keep watchers localized to the components that need side effects.
Section 16 — Advanced example: computed with setter + watcher for persistence
This pattern is useful when you want to present a friendly value and persist changes automatically.
// Options API: fullName computed with setter, watch to persist to server
data() { return { firstName: 'John', lastName: 'Doe', saveTimer: null } },
computed: {
fullName: {
get() { return `${this.firstName} ${this.lastName}` },
set(val) {
const [first, ...rest] = val.split(' ')
this.firstName = first || ''
this.lastName = rest.join(' ') || ''
}
}
},
watch: {
fullName(newVal) {
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(() => {
// pseudo-save
fetch('/api/save-name', { method: 'POST', body: JSON.stringify({ fullName: newVal }) })
}, 600)
}
}Here the computed handles the user-friendly view and editing; the watch takes responsibility for persisting the change (a side effect).
Section 17 — FAQ
Q. Can I use methods instead of computed always?
A. You can, but you'll lose caching. Recalculations may be expensive and cause rendering overhead.
Q. When should I use watchEffect?
A. Use watchEffect for quick, implicit reactive effects that reference multiple reactive sources. Use watch when you need old/new values or more control.
Q. Can watchers be async?
A. Yes. Watcher callbacks can be async functions. Remember to manage loading/error state and to cancel or ignore stale results (e.g., using request tokens or local counters).
Section 18 — Summary and final recommendations
To wrap up: computed properties are for derived, cached state; methods are for actions and event-driven code; watchers are for side effects when reactive data changes. Use each tool for its intended job and your Vue apps will be faster, easier to reason about, and far less likely to surprise you in production.
Section 19 — Extra: migration notes (Vue 2 -> Vue 3)
Most patterns remain. The Composition API makes it easier to colocate logic (computed, watch, methods) inside setup(). If you're migrating, gradually extract logic into composables (useSearch(), useDebounced, useFilteredList), and write unit tests around them.
Closing thoughts (and a tiny challenge)
If you read till here: congrats! You're ready for a challenge — refactor a component in your codebase that currently uses multiple watchers into one that uses computed + a single watcher for necessary side-effects. Drop me a note in the comments (or in your own Git commit message). I did this once in a CRM app and reduced CPU usage by ~30% — not bragging, just saying it felt good.






