Understanding the Choice: Computed vs Watch
By Day 7 of building with Vue and Nuxt, you've likely reached the moment where reactive state alone isn't enough. You need derived values, or you need to react to changes by making API calls. The question of computed vs watch: Vue & Nuxt best practices explained separates developers who build maintainable components from those who end up chasing circular dependency bugs and unintended side effects. This is foundational knowledge that shapes how cleanly your codebase scales.
The core distinction is simple but profound: computed properties derive new values from existing state, while watchers execute arbitrary logic when state changes. Getting this wrong leads to performance problems, hard-to-debug reactivity chains, and components that are difficult for teammates to reason about. Let's work through both patterns with production-tested examples.
Computed for Derived Values
A computed property transforms existing reactive data into a new value. Vue caches the result and only recalculates when dependencies change. This is the hammer you reach for when you need to transform, filter, or combine state.
Why computed first: They are synchronous, declarative, and naturally lazy. If no component reads the computed value, Vue skips the calculation entirely. They're perfect for the most common use case: preparing data for display.
Consider an e-commerce filter page. You have a list of products and multiple filter selections. A computed property naturally expresses "filtered products":
import { computed, ref } from 'vue'
export default {
setup() {
const products = ref([
{ id: 1, name: 'Laptop', category: 'Electronics', price: 1200 },
{ id: 2, name: 'Desk', category: 'Furniture', price: 300 },
{ id: 3, name: 'Monitor', category: 'Electronics', price: 400 },
])
const selectedCategory = ref('Electronics')
const minPrice = ref(0)
const filteredProducts = computed(() => {
return products.value.filter(
(product) =>
product.category === selectedCategory.value &&
product.price >= minPrice.value
)
})
return { filteredProducts, selectedCategory, minPrice }
},
}
The component template simply uses filteredProducts. If the user never changes the filter, Vue never re-runs the filter logic. This is efficient and declarative. The data flow is explicit: products in, filtered products out.
Computed properties also work beautifully in Nuxt, with the same semantics. You can even use them in server-side rendering contexts because they're pure functions with no side effects.
Watch for Side Effects and API Calls
A watcher executes a callback function when one or more reactive dependencies change. Use watchers when you need to perform side effects: API calls, analytics tracking, updating the browser title, or triggering external systems.
When to reach for watch: The dependency changed, and now we need to do something, not just compute a new value. That "something" is almost always an async operation or an effect on the outside world.
Real-world scenario: a CRM dashboard where selecting a customer should fetch their recent interactions from an API:
import { ref, watch } from 'vue'
export default {
setup() {
const selectedCustomerId = ref(null)
const customerInteractions = ref([])
const isLoading = ref(false)
watch(
selectedCustomerId,
async (newId) => {
if (!newId) return
isLoading.value = true
try {
const response = await fetch(`/api/customers/${newId}/interactions`)
customerInteractions.value = await response.json()
} catch (error) {
console.error('Failed to fetch interactions:', error)
} finally {
isLoading.value = false
}
}
)
return { selectedCustomerId, customerInteractions, isLoading }
},
}
The watcher responds to a change in selectedCustomerId and triggers a fetch. The side effect (the async call) is explicit and lives in the watcher callback. This keeps your data fetching logic separate from your derived-value logic.
You can also watch multiple sources and access both old and new values:
watch(
[selectedCustomerId, filterType],
async ([customerId, filter], [oldCustomerId, oldFilter]) => {
if (!customerId) return
// Re-fetch when either dependency changes
const interactions = await fetchInteractions(customerId, filter)
customerInteractions.value = interactions
}
)
WatchEffect Tracks Dependencies Automatically
watchEffect is Vue's way of saying "run this function, track everything it touches, and re-run when any of those dependencies change." It's syntactic sugar that removes the need to explicitly list dependencies.
In practice, use watchEffect when your side effect depends on multiple reactive values and listing them feels repetitive:
import { ref, watchEffect } from 'vue'
export default {
setup() {
const userId = ref(1)
const includeArchived = ref(false)
const orders = ref([])
watchEffect(async () => {
// Dependencies tracked automatically
const response = await fetch(
`/api/users/${userId.value}/orders?archived=${includeArchived.value}`
)
orders.value = await response.json()
})
return { userId, includeArchived, orders }
},
}
Vue tracks that the effect reads userId and includeArchived, so it re-runs whenever either changes. No need to manually list [userId, includeArchived].
The trade-off: watchEffect is slightly less explicit about dependencies. Senior teams often prefer the explicit watch([dep1, dep2], callback) syntax for clarity in code reviews. In Nuxt, both patterns work seamlessly, but be mindful of which dependencies affect SSR.
Avoid Watchers That Set Other State
This is where reactive systems break down. When a watcher modifies state that another watcher depends on, you create implicit dependencies that are hard to trace. The result: cascading updates, infinite loops, or stale data that confuses teammates.
The anti-pattern:
// ❌ Don't do this
const userRole = ref('guest')
const permissions = ref([])
watch(userRole, (newRole) => {
// Watcher A: modifies state
permissions.value = computePermissions(newRole)
})
watch(permissions, (newPerms) => {
// Watcher B: depends on modified state
sendAnalytics('permissions-changed', newPerms)
})
The flow is now hidden. Did permissions change because the user selected a new role, or because some other code updated it? This becomes a maintenance nightmare.
The clean alternative: use computed for the derived value.
// ✅ Do this instead
import { ref, computed, watch } from 'vue'
const userRole = ref('guest')
const permissions = computed(() => {
// Derived value, not modified by watchers
return computePermissions(userRole.value)
})
watch(permissions, (newPerms) => {
// Watcher only triggers when the derived value changes
sendAnalytics('permissions-changed', newPerms)
})
Now the data flow is crystal clear. permissions is a pure function of userRole. The watcher responds to that computed change. No hidden side effects modifying state.
In larger Nuxt applications, this pattern scales. Computed properties form a dependency graph that Vue understands. Watchers sit at the edges, triggering effects only when derived values change.
Common Mistakes
Using watch when computed would work: If you're tempted to write a watcher that sets state without side effects, stop. Use computed instead. Computed is cached, synchronous, and testable.
Forgetting deep watchers for objects: By default, watchers only trigger on reference changes. If you mutate an object property without reassigning it, the watcher won't fire. Use { deep: true }:
const user = ref({ name: 'Alice', age: 30 })
watch(
user,
(newUser) => {
console.log('User changed:', newUser)
},
{ deep: true } // Needed to detect mutations
)
Watchers in Nuxt plugin hooks: If you register a watcher in a Nuxt plugin without cleanup, it persists across page transitions. Use watch's return value to stop the watcher, or register watchers in component setup where they're automatically cleaned up.
Immediate flag surprises: watch(..., { immediate: true }) runs the callback on setup, even if the dependency hasn't changed yet. This can cause double fetches or unexpected behavior. Use it only when you explicitly need initialization.
Key Takeaways
- Computed: Use for derived values, filtering, mapping, or combining state. Cached and lazy. Zero side effects.
- Watch: Use for side effects—API calls, analytics, DOM updates, or external system triggers. Explicit dependency list or use
watchEffectfor automatic tracking. - WatchEffect: Handy when you depend on many reactive values, but less explicit than
watch. Good for complex derived logic with side effects. - Avoid watcher-to-watcher state modifications: They hide data flow. Use computed for derivation, watchers for side effects.
- In Nuxt: Computed properties work in SSR and static generation. Watchers should be scoped to components or carefully managed in plugins to avoid memory leaks.
- Testability: Computed properties are pure functions—easy to test in isolation. Watchers involve async logic and side effects—mock them carefully.






Comments
Loading comments…