Skip to main content

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

vue
<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

FeatureComputedMethodsWatchers
CachingYes, dependency-basedNoNo
Returns valueAlwaysOptionalNo
TriggerGetter access + dep changeEvery callSource change
Async supportNo (sync only)YesYes
Side effectsNot recommendedFineDesigned for them
Best forTemplate display valuesEvent handlers, parameterized callsAPI 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

vue
<!-- 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

vue
<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

vue
<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

vue
<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

vue
// 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: total computed 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

vue
<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

vue
<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

vue
<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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?