| |

Vue.js 23 🟢 Automatic Dependency Tracking with watchEffect()

The watch() function requires the source to be named. The getter is written, the refs are listed, and the watcher knows exactly what it depends on. The watchEffect() is the opposite. It takes a function, runs it immediately, and tracks every reactive value the function reads. The dependency list is implicit, and it is derived from what the function actually touches. The result is less ceremony and a subtle failure mode: the effect depends on whatever it reads on the current run, and a conditional read can make the dependency list change between runs.

Key point: watchEffect() runs the callback immediately and tracks the reactive dependencies automatically. When any of the tracked dependencies change, the callback re-runs. The dependencies are re-collected on every run, so a dependency that is only read in a branch is only tracked when the branch executes. The callback receives the onCleanup function, and it can register the cleanup for the next run. The flush option controls the timing, and the onTrack and onTrigger options are for the debugging.


Why watchEffect exists

The explicit-source problem. The watch() requires the source to be named. A side effect that reads five reactive values needs five sources, or a getter that reads all five. The watchEffect() reads the values in the callback and tracks them automatically. The dependency list is the read list, and it is maintained by the framework.

The immediate problem. The watch() is lazy. The watchEffect() runs immediately. The side effect that should run on the creation and on every change is the default behavior, not the option. The watchEffect is for the effects that are “always in sync with the state.”

The conditional-dependency problem. The watchEffect re-collects the dependencies on every run. A callback that reads a only when b is true depends on b and, when b is true, on a. When b becomes false, the a dependency is dropped. The behavior is correct for the conditional side effects, and it is the source of the confusion when the dependency is expected but not tracked.

The cleanup problem. The watchEffect callback receives the onCleanup, the same as the watch callback. The cleanup runs before the next invocation and when the effect stops. The pattern is the same as the watch, and the watchEffect makes it available without the explicit source.

The readability problem. The watchEffect reads as a function that runs when its dependencies change. The watch reads as a function that runs when the named source changes. The watchEffect is often shorter, and the watch is often clearer about what triggers the run. The choice is the design decision.


a. The basic watchEffect and the automatic tracking

The watchEffect() runs the callback immediately and tracks the reactive values that the callback reads.

<script setup>
import { ref, watchEffect } from 'vue'

const count = ref(0)
const doubled = ref(0)

watchEffect(() => {
  doubled.value = count.value * 2
})

console.log(doubled.value)  // 0 (runs immediately)

count.value = 5
console.log(doubled.value)  // 10 (runs on the change)

The callback runs immediately and reads the count. The count is tracked. When the count changes, the callback re-runs, and the doubled updates.

The watchEffect returns the stop function.

<script setup>
const stop = watchEffect(() => {
  console.log(count.value)
})

stop()  // stop the effect

The effect is stopped. The callback does not run again, and the dependencies are released.

The onCleanup is the second argument of the callback.

<script setup>
watchEffect((onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())

  fetch(`/api/search?q=${query.value}`, {
    signal: controller.signal
  })
})
</script>

The cleanup runs before the next invocation. The controller.abort() cancels the fetch when the query changes. The pattern is the same as the watch cleanup, and the watchEffect provides it without the explicit source.

The onWatcherCleanup() is the alternative in the Vue 3.5+.

<script setup>
import { watchEffect, onWatcherCleanup } from 'vue'

watchEffect(() => {
  const controller = new AbortController()
  onWatcherCleanup(() => controller.abort())
  fetch(`/api?q=${query.value}`, { signal: controller.signal })
})
</script>

The onWatcherCleanup is called inside the callback and registers the cleanup. The two forms are equivalent, and the onWatcherCleanup is the newer API.


b. The conditional dependencies and the re-collection

The watchEffect re-collects the dependencies on every run. The dependency list is what the callback reads on the current run, and it changes when the reads change.

<script setup>
import { ref, watchEffect } from 'vue'

const a = ref(0)
const b = ref(true)
const c = ref(0)

watchEffect(() => {
  console.log('effect runs')
  if (b.value) {
    console.log(a.value)
  }
  console.log(c.value)
})

The callback reads the b, the c, and, when the b is true, the a. The dependencies are the b, the c, and the a (when the b is true). When the b becomes false, the a is no longer read, and the a dependency is dropped.

a.value = 1  // effect runs (a is tracked, b is true)
b.value = false  // effect runs (b is tracked), a is dropped
a.value = 2  // effect does NOT run (a is not tracked)
c.value = 1  // effect runs (c is tracked)

The behavior is the conditional dependency. The effect depends on the a only while the b is true. The pattern is the correct one for the conditional side effects, and it is the source of the confusion when the dependency is expected but not tracked.

The fix is to always read the dependency, even when the value is not used.

<script setup>
watchEffect(() => {
  const aValue = a.value  // always read
  if (b.value) {
    console.log(aValue)
  }
})

The a is always read, so the a is always tracked. The conditional logic uses the read value.

The onTrack and the onTrigger are the debugging options.

<script setup>
watchEffect(() => {
  console.log(count.value)
}, {
  onTrack(e) {
    console.log('tracked', e.key)
  },
  onTrigger(e) {
    console.log('triggered', e.key)
  }
})
</script>

The onTrack runs when the dependency is tracked. The onTrigger runs when the dependency triggers the re-run. The options are for the debugging, not for the production.


c. The flush, the comparison, and the choice

The flush option controls the timing of the effect.

FlushTiming
pre (default)Before the component updates
postAfter the component updates
syncSynchronously on the change
<script setup>
watchEffect(() => {
  console.log(el.value?.offsetHeight)
}, { flush: 'post' })
</script>

The flush: 'post' is for the effects that read the updated DOM. The default is pre, which is the timing before the DOM update.

The watchEffect and the watch are the two tools for the side effects.

AspectwatchwatchEffect
RunLazyImmediate
DependenciesExplicitAutomatic
Old valueYesNo
Conditional depsRe-collectedRe-collected
UseSpecific sourceMultiple dependencies

The watch is the explicit source and the old value. The watchEffect is the automatic dependency tracking and the immediate run. The choice is the design decision.

The watchEffect does not have the deep option because the automatic tracking is already deep. The reactive object’s nested properties are tracked when they are read. The watch has the deep option because the explicit source is the object reference, and the nested changes need the option.

The watchEffect can be used for the effects that read the multiple reactive values.

<script setup>
import { ref, watchEffect } from 'vue'

const firstName = ref('Alice')
const lastName = ref('Smith')
const fullName = ref('')

watchEffect(() => {
  fullName.value = `${firstName.value} ${lastName.value}`
})
</script>

The callback reads the firstName and the lastName. Both are tracked. The fullName updates when either changes.

The comparison with the computed: the watchEffect is the side effect, and the computed is the value. The effect assigns to the fullName, which is the state. The computed would derive the fullName, which is the value. The computed is the right tool for the derived value, and the watchEffect is the right tool for the side effect.

The watchEffect is not for the derived value. The example above is the anti-pattern: the fullName should be a computed.

<script setup>
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
</script>

The computed is the derived value, and the watchEffect is the side effect. The two are different tools, and the choice is the design decision.

The watchEffect can be used for the multiple side effects.

<script setup>
watchEffect(() => {
  document.title = `${count.value} clicks`
  localStorage.setItem('count', count.value)
})
</script>

The callback reads the count and performs the two side effects. The count is tracked, and both effects run when it changes.


Complete Example Session

<!-- ============================================ -->
<!-- PART 1: BASIC WATCHEFFECT -->
<!-- ============================================ -->
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)

watchEffect(() => {
  console.log(count.value)
})
// Logs 0 immediately
</script>
<!-- ============================================ -->
<!-- PART 2: SIDE EFFECT -->
<!-- ============================================ -->
<script setup>
const doubled = ref(0)

watchEffect(() => {
  doubled.value = count.value * 2
})
</script>
<!-- ============================================ -->
<!-- PART 3: CLEANUP -->
<!-- ============================================ -->
<script setup>
watchEffect((onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  fetch(`/api?q=${query.value}`, { signal: controller.signal })
})
</script>
<!-- ============================================ -->
<!-- PART 4: ONWATCHERCLEANUP -->
<!-- ============================================ -->
<script setup>
import { watchEffect, onWatcherCleanup } from 'vue'

watchEffect(() => {
  const id = setInterval(() => console.log(count.value), 1000)
  onWatcherCleanup(() => clearInterval(id))
})
</script>
<!-- ============================================ -->
<!-- PART 5: CONDITIONAL DEPENDENCY -->
<!-- ============================================ -->
<script setup>
watchEffect(() => {
  if (b.value) {
    console.log(a.value)
  }
  console.log(c.value)
})
</script>
<!-- ============================================ -->
<!-- PART 6: ALWAYS READ -->
<!-- ============================================ -->
<script setup>
watchEffect(() => {
  const aValue = a.value  // always tracked
  if (b.value) {
    console.log(aValue)
  }
})
</script>
<!-- ============================================ -->
<!-- PART 7: FLUSH POST -->
<!-- ============================================ -->
<script setup>
watchEffect(() => {
  console.log(el.value?.offsetHeight)
}, { flush: 'post' })
</script>
<!-- ============================================ -->
<!-- PART 8: STOP FUNCTION -->
<!-- ============================================ -->
<script setup>
const stop = watchEffect(() => console.log(count.value))
// Later:
stop()
</script>
<!-- ============================================ -->
<!-- PART 9: DEBUG -->
<!-- ============================================ -->
<script setup>
watchEffect(() => {
  console.log(count.value)
}, {
  onTrack: (e) => console.log('tracked', e.key),
  onTrigger: (e) => console.log('triggered', e.key),
})
</script>
<!-- ============================================ -->
<!-- PART 10: MULTIPLE SIDE EFFECTS -->
<!-- ============================================ -->
<script setup>
watchEffect(() => {
  document.title = `${count.value} clicks`
  localStorage.setItem('count', count.value)
})
</script>

The ten parts covered the basic watchEffect, the side effect, the cleanup, the onWatcherCleanup, the conditional dependency, the always-read fix, the flush: 'post', the stop function, the debug, and the multiple side effects.


Quick Reference

watchEffect Syntax

FormBehavior
watchEffect(cb)Run immediately, track automatically
watchEffect(cb, opts)With the options
watchEffect(cb) returnStop function

Callback Arguments

ArgumentValue
onCleanupRegister the cleanup

Options

OptionEffect
flush: 'pre'Before the DOM update
flush: 'post'After the DOM update
flush: 'sync'Synchronously
onTrackDebug the tracking
onTriggerDebug the trigger

watch vs watchEffect

AspectwatchwatchEffect
RunLazyImmediate
DependenciesExplicitAutomatic
Old valueYesNo
DeepOptionAutomatic
UseSpecific sourceMultiple deps

Cleanup APIs

APIVersion
onCleanup argument3.0+
onWatcherCleanup3.5+

Best Practices

✅ Do This:

<!-- Use watchEffect for the multiple dependencies -->
watchEffect(() => {
  document.title = `${count.value} items`
})                                                              // ✅

<!-- Use the cleanup for the async -->
watchEffect((onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  fetch(`/api?q=${query.value}`, { signal: controller.signal })
})                                                              // ✅

<!-- Always read the dependencies that should be tracked -->
watchEffect(() => {
  const aValue = a.value
  if (b.value) console.log(aValue)
})                                                              // ✅

<!-- Use flush: 'post' for the DOM reads -->
watchEffect(() => {
  console.log(el.value?.offsetHeight)
}, { flush: 'post' })                                           // ✅

<!-- Use the computed for the derived values -->
const fullName = computed(() => `${first.value} ${last.value}`)  // ✅

❌ Don’t Do This:

<!-- Don't use watchEffect for the derived value -->
watchEffect(() => {
  fullName.value = `${first.value} ${last.value}`  // use computed// ⚠️
})

<!-- Don't read the dependency conditionally when it should always track -->
watchEffect(() => {
  if (b.value) console.log(a.value)  // a not tracked when b false // ⚠️
})

<!-- Don't forget the cleanup for the async -->
watchEffect(() => {
  fetch(`/api?q=${query.value}`)  // no cleanup                    // ❌
})

<!-- Don't assign to the tracked source -->
watchEffect(() => {
  count.value = count.value + 1  // infinite loop                  // ❌
})

<!-- Don't use watchEffect when the source is specific -->
watchEffect(() => {
  console.log(count.value)  // watch would be clearer             // ⚠️
})

<!-- Don't forget the effect is immediate -->
watchEffect(() => {
  logAnalytics()  // runs on the creation                          // ⚠️
})

Common Pitfalls

PitfallWhy It HappensFix
Dependency not trackedConditional readAlways read
Effect runs too oftenAutomatic trackingCheck the reads
Race conditionNo cleanupUse onCleanup
Infinite loopAssigning to the sourceDon’t assign
DOM not updatedflush: 'pre'Use flush: 'post'
Runs on the creationImmediate by defaultUse watch for lazy

Real-World Examples

1. Document Title

watchEffect(() => {
  document.title = `${count.value} clicks`
})

2. localStorage Sync

watchEffect(() => {
  localStorage.setItem('theme', theme.value)
})

3. Fetch with Abort

watchEffect((onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  fetch(`/api/users/${userId.value}`, { signal: controller.signal })
    .then(r => r.json())
    .then(d => user.value = d)
})

4. Multi-Source Log

watchEffect(() => {
  console.log(`${firstName.value} ${lastName.value}`)
})

5. DOM Read After Update

watchEffect(() => {
  console.log(el.value?.offsetHeight)
}, { flush: 'post' })

6. Timer with Cleanup

watchEffect((onCleanup) => {
  const id = setInterval(() => console.log(count.value), 1000)
  onCleanup(() => clearInterval(id))
})

7. Analytics

watchEffect(() => {
  trackPageView(route.fullPath)
})

8. Form Autosave

watchEffect((onCleanup) => {
  const id = setTimeout(() => save(form), 500)
  onCleanup(() => clearTimeout(id))
})

9. Conditional Tracking

watchEffect(() => {
  const id = userId.value
  if (id) {
    fetch(`/api/users/${id}`)
  }
})

10. Stop on Condition

const stop = watchEffect(() => {
  if (count.value >= 10) stop()
})

Visual

watchEffect Flow

┌─────────────────────────────────────────────────────────────┐
│  watchEffect(() => { console.log(count.value) })            │
│    │                                                        │
│    ▼                                                        │
│  Run immediately                                            │
│    │                                                        │
│    ├── Read count ──▶ Track count                           │
│    │                                                        │
│    └── Log 0                                                │
│                                                             │
│  count.value = 5                                            │
│    │                                                        │
│    ▼                                                        │
│  count is a tracked dependency                              │
│    │                                                        │
│    ▼                                                        │
│  Re-run the callback                                        │
│    │                                                        │
│    ├── Read count ──▶ Track count (again)                   │
│    │                                                        │
│    └── Log 5                                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Conditional Dependencies

┌─────────────────────────────────────────────────────────────┐
│  watchEffect(() => {                                        │
│    if (b.value) {                                           │
│      console.log(a.value)                                   │
│    }                                                        │
│    console.log(c.value)                                     │
│  })                                                         │
│                                                             │
│  Run 1: b = true                                            │
│    Tracks: b, a, c                                          │
│                                                             │
│  b = false                                                  │
│    Re-run: tracks b, c (a is not read)                      │
│                                                             │
│  a = 1                                                      │
│    No re-run (a is not tracked)                             │
│                                                             │
│  c = 1                                                      │
│    Re-run (c is tracked)                                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Cleanup and Race Condition

┌─────────────────────────────────────────────────────────────┐
│  watchEffect((onCleanup) => {                               │
│    const controller = new AbortController()                 │
│    onCleanup(() => controller.abort())                      │
│    fetch(`/api?q=${query.value}`, { signal: controller.signal })│
│  })                                                         │
│                                                             │
│  query = 'a'                                                │
│    Fetch 'a' starts                                         │
│                                                             │
│  query = 'b'                                                │
│    Cleanup runs ──▶ abort 'a' fetch                         │
│    Fetch 'b' starts                                         │
│                                                             │
│  The 'a' response never arrives.                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

watch vs watchEffect

┌─────────────────────────────────────────────────────────────┐
│  WATCH                                                      │
│                                                             │
│  watch(count, (newVal, oldVal) => { })                      │
│  - Lazy: runs on the change                                 │
│  - Explicit source                                          │
│  - Old value available                                      │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  WATCHEFFECT                                                │
│                                                             │
│  watchEffect(() => { console.log(count.value) })            │
│  - Immediate: runs on the creation                          │
│  - Automatic dependencies                                   │
│  - No old value                                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
FunctionwatchEffect(callback, options)
RunImmediate
DependenciesAutomatic
Old valueNo
CleanuponCleanup, onWatcherCleanup
StopReturn function
flushpre, post, sync
DebugonTrack, onTrigger
Conditional depsRe-collected
vs watchImmediate vs lazy

Key takeaways:

  • watchEffect() runs immediately and tracks the dependencies automatically. The callback reads the reactive values, and the framework tracks them. The effect re-runs when any of the tracked values change.
  • The dependencies are re-collected on every run. A dependency that is only read in a branch is only tracked when the branch executes. The conditional read is the correct behavior for the conditional side effects, and the source of the confusion when the dependency is expected.
  • The cleanup is the onCleanup argument. The cleanup runs before the next invocation and when the effect stops. The onWatcherCleanup is the newer API in the Vue 3.5+.
  • The flush option controls the timing. The pre is the default (before the DOM update), the post is after the DOM update, and the sync is synchronous.
  • The watchEffect is not for the derived value. The computed is the derived value, and the watchEffect is the side effect. The assignment to the state in the watchEffect is the anti-pattern.
  • The effect is stopped when the component unmounts. The cleanup runs, and the resources are released. The explicit stop() is for the effects that should stop earlier.
  • The watchEffect is often shorter than the watch. The automatic tracking removes the explicit source. The watch is often clearer about what triggers the run. The choice is the design decision.

Remember: The watchEffect is the automatic dependency tracking and the immediate run. It is for the side effects that read the multiple reactive values and should run on the creation. The cleanup is the mechanism for the async and the timer. The conditional dependencies are the behavior to know: the dependency is tracked when it is read, and it is dropped when it is not. Always read the dependency that should always be tracked. And use the computed for the derived value, not the watchEffect. The two are different tools, and the choice is the design decision.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!