Vue 3: watchEffect is Impressive, but watch is still the Best Choice

Fotis Adamakis
5 min readMar 22, 2023

One of the most powerful features of Vue is the ability to reactively perform a side-effect based on changes to the underlying data. To achieve this, Vue 3 provides two helpers: watch and watchEffect. While both helpers can monitor changes to reactive data, they have different use cases and behaviours. This article will explore their differences and when to use each.

Watch

The watch helper allows us to watch one or more reactive data sources and invoke a callback function when there is an update.

The syntax is different, but it works the same as in Vue 2:

import { watch } from 'vue'

watch(source, callback, options?)

If you are a Vue 2 expert you might want to skip straight to watchEffect since parameters remained the same.

Watch() accepts 3 parameters:

The watcher’s source that can be one of the following:

  • A getter function or a computed that returns a value
  • A ref
  • A reactive object
  • An array of any of the above

A callback function which is invoked when the source(s) change and receives the following arguments:

  • value: The new value of the watched state.
  • oldValue: The old value of the watched state.
  • onCleanup: A function that can be used to register a cleanup callback. The cleanup callback will be called right before the next time the effect is re-run and can be used to clean up invalidated side effects, such as a pending async request.

And an optional options object that supports the following options:

  • immediate: A boolean indicating whether the callback should be triggered immediately on watcher creation. The old value will be undefined on the first call.
  • deep: A boolean indicating whether to perform a deep traversal of the source if it is an object, so that the callback fires on deep mutations. See Deep Watchers.
  • flush: A string indicating how to adjust the callback’s flush timing. See Callback Flush Timing.
  • onTrack / onTrigger: Functions to debug the watcher’s dependencies when they are triggered. See Watcher Debugging.

The watch() function is lazy by default, meaning the callback is only called when the watched source has changed.

When watching multiple sources, the callback receives two arrays containing new and old values corresponding to the source array.

Compared to watchEffect(), watch() allows us to:

  • Perform the side effect lazily
  • Be more specific about what state should trigger the watcher to re-run
  • Access both the previous and current value of the watched state.

Examples

Watching a getter:

const state = reactive({ count: 0 })
watch(
() => state.count,
(count, prevCount) => {
console.log(count, prevCount)
}
)

Watching a ref:

const count = ref(0)
watch(count, (count, prevCount) => {
console.log(count, prevCount)
})

When watching multiple sources, the callback receives arrays containing new and old values corresponding to the source array:


watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
console.log("New values:" , foo, bar)
console.log("Old values:" , prevFoo, prevBar)
})

WatchEffect ✨

watchEffect runs a callback function immediately and automatically tracks its reactive dependencies. The callback function is executed whenever any of the reactive dependencies change.

Here is an example:

import { reactive, watchEffect } from "vue"

const state = reactive({
count: 0,
name: 'Leo'
})

watchEffect(() => {
// Runs immediately
// Logs "Count: 0, Name: Leo"
console.log(`Count: ${state.count}, Name: ${state.name}`)
})

state.count++ // logs "Count: 1, Name: Leo"
state.name = 'Cristiano' // logs "Count: 1, Name: Cristiano"

In the example above, we monitor changes to the state, count and name properties using watchEffect. The callback function logs the current values of count and name whenever either of them changes.

The first time watchEffect is called, the callback function is immediately executed with the current values of count and name. After that, the callback function is re-run whenever any reactive dependency (count or name) changes.

Just like watch, watchEffect has a few additional features that make it even more powerful. You can pass an options object as the second argument to configure the behaviour of the watcher. For example, you can specify the flush timing (when the watcher should be executed) or add debugging hooks.

The callback function receives a special function called onCleanup as its first argument. You can use this function to register a cleanup callback that will be called before the watcher is re-executed. This is useful for cleaning up resources that are no longer needed.

import { ref, watchEffect } from "vue"

const id = ref(1)
const data = ref(null)

watchEffect(async (onCleanup) => {
const { response, cancel } = await fetch(`https://example.com/api/data/${id.value}`)
onCleanup(cancel)
data.value = response.data
})

In the example above, we are using watchEffect to fetch data from an API whenever the id property changes. We use the onCleanup function to register a cancel function that will cancel the fetch request if the id property changes before the request is complete.

Additionally, you can use the return value of watchEffect to stop the watcher.

import { watchEffect } from "vue"

const stop = watchEffect(() => {
// …
})
// Stop the watcher
stop()

⚠️ ⚠️ ⚠️ Another very confusing caveat is that watchEffect only tracks dependencies during its synchronous execution. Every property accessed before the first await tick will be tracked when using it with an async callback, but every property after will not.

This is mentioned in a small and easy-to-miss sidenote in the official documentation and has caused me a lot of confusion when I had to debug it.

The problem is demonstrated in the snippet below.

<script setup>
import { reactive, watchEffect } from "vue"

const state = reactive({
count: 0,
name: 'Leo'
})

const sleep = ms => new Promise(
resolve => setTimeout(resolve, ms));

watchEffect(async () => {
console.log(`Count: ${state.count}`) // This will be tracked
await sleep(200)
console.log(`Name: ${state.name}`) // This will NOT be tracked
})


</script>


<template>
<input type="number" v-model="state.count" />
<br />
<input v-model="state.name" />
</template>

Everything before the await (i.e. state.count) will be tracked

Everything after the await (i.e. state.name ) will NOT be tracked

Try it yourself

Summary

The functions watch and watchEffect both allow us to perform side effects reactively, but they differ in how they track dependencies.

  • Watch tracks only the explicitly watched source and won’t track anything accessed inside the callback. Additionally, the callback only triggers when the source has actually changed. By separating dependency tracking from the side effect, watch provides more precise control over when the callback should fire.
  • WatchEffect, on the other hand, combines dependency tracking and side effect into one phase. During its synchronous execution, it automatically tracks every reactive property accessed. This results in more concise code but makes its reactive dependencies less explicit.

Conclusion

For watchers with multiple dependencies, using watchEffect() removes the burden of manually maintaining the list of dependencies.

In addition, if you need to watch several properties in a nested data structure, watchEffect() it may prove more efficient than a deep watcher, as it will only track the properties used in the callback, rather than recursively tracking all of them.

But this is a double-ended sword. It requires critical thinking every time the callback function is updated because each new addition will be added to the dependencies and will trigger an evaluation when it changes.

Using watch() and being explicit with your dependencies requires slightly more effort but ensures better overall control, separates dependency tracking from the side effect, avoids accidental performance degradations and, in my opinion, using watch() should be considered the best practice.

--

--

« Senior Software Engineer · Author · International Speaker · Vue.js Athens Meetup Organizer »