In Vue 3, why does an async watchEffect() ignore changes to a ref it reads after an await, and how do you fix it?
answer
- the run ends at the first await
- no active effect in the continuation
- read inputs up front
- or name the sources
basics
~20 swatchEffect() tracks only reads made during its synchronous run; an async function's run ends, from Vue's view, at the first await. Reads after it happen with no active effect. Read every dependency before the first await, or use watch() with explicit sources.
solid answer
~40 sVue records dependencies while the effect function is running synchronously. An `async` function runs synchronously only up to its first `await`; then it returns a promise, Vue considers the run finished and restores the previous tracking state. The continuation runs later in a microtask with no active effect, so a ref read there - say `lang.value` after `await fetch(...)` - is never registered, and changing it does nothing. Vue gives no warning. Fix it by reading every input before the first `await` (`const l = lang.value` at the top), or by switching to `watch([id, lang], async ([id, l]) => ...)`, where the sources are declared and the callback can be as async as it likes. Handling responses that arrive out of order is a separate concern.
code
ts · 13 linesimport { ref, watchEffect } from 'vue'
const id = ref(1)
const lang = ref('en')
const title = ref('')
watchEffect(async () => {
const articleId = id.value // tracked
const language = lang.value // tracked: read before the await
const res = await fetch(`/api/articles/${articleId}`)
const article: { title: string } = await res.json()
title.value = `${article.title} [${language}]` // untracked zone: uses captured values
})go deeper
Remember the rule: in an async watchEffect only reads before the first await are tracked.
Explain the mechanism: tracking needs an active effect, the async function returns at the first await, and the continuation runs in a microtask with none.
Recognise the 'changing X does nothing' symptom, choose between reading inputs up front and an explicit watch source, and keep inputs consistent across the await.
Consider a convention that async side effects use watch() with declared sources, so the dependency list is visible and cannot silently shrink.
## The symptom ```ts watchEffect(async () => { const res = await fetch(`/api/articles/${id.value}`) const article = await res.json() title.value = translate(article.title, lang.value) }) ``` Changing `id` refetches. Changing `lang` does **nothing** - the title stays in the old language until `id` happens to change too. No error, no warning. ## Why: tracking only exists during the synchronous run Vue's dependency tracking works through an **active effect**: while an effect's function runs, Vue marks it as the current subscriber, and every reactive read registers against it. When the function returns, Vue restores the previous state. An `async` function interacts with this in a specific way: 1. `watchEffect` calls the function. It runs synchronously until the first `await`. 2. Everything read so far - here `id.value` - is tracked. 3. At the `await`, the function **returns a promise**. Vue's call is over; it restores the tracking state. 4. Later, the promise settles and the rest of the function runs in a **microtask**. No effect is active. 5. `lang.value` is read, but there is nobody to register it against. It is not a dependency. The Vue guide states the rule directly: `watchEffect` only tracks dependencies during its **synchronous** execution, and with an async callback only properties accessed before the first `await` are tracked. ## Why Vue cannot follow the `await` JavaScript gives a library no hook that says "this microtask continues the function that effect X started". Vue's tracking therefore relies on a single piece of state - the currently active subscriber - that is set right before the effect function is called and restored right after it returns. Any code that runs later, whether it is the rest of an `async` function, a promise callback or a timer, runs outside that window. The limitation is structural, not a missing feature, which is why the fixes are about **moving reads** or **declaring sources**, not about configuration. ## Fix 1: read every input before the first `await` ```ts watchEffect(async () => { const articleId = id.value const language = lang.value // tracked: read synchronously const res = await fetch(`/api/articles/${articleId}`) const article = await res.json() title.value = translate(article.title, language) }) ``` - Both refs are dependencies now; changing either re-runs the effect. - Using the captured locals after the `await` also keeps the run consistent: it finishes with the inputs it started with. ## Fix 2: declare the sources with `watch()` ```ts watch([id, lang], async ([articleId, language]) => { const res = await fetch(`/api/articles/${articleId}`) title.value = translate((await res.json()).title, language) }, { immediate: true }) ``` With `watch()`, tracking happens in the **source**, which is evaluated synchronously by Vue; the callback is free to be async. This is the better choice when the dependency list matters for correctness and should be visible to reviewers. ## Things that look like fixes but are not - **`{ deep: true }` on `watchEffect`** - `deep` is a `watch()` option; with `watchEffect` Vue warns in development that it is only respected with the `watch(source, cb)` signature. It would not help anyway: the problem is *when* the read happens, not how deep. - **Changing `flush`** - `'pre'`, `'post'` and `'sync'` change when the effect is scheduled, not whether an async continuation is tracked. - **Wrapping reads in `nextTick()` or `setTimeout`** - these run even later, with no active effect either. ## Related subtlety: writes after the `await` Writing reactive state after an `await` is fine - writes trigger other effects normally. But because the continuation is untracked, **reads** used to decide what to write there must come from values captured earlier. A common pattern is: read inputs, `await`, then write outputs. ## Summary table | Where the read happens | Tracked by `watchEffect`? | |---|---| | Before the first `await` | yes | | After any `await` | no | | Inside a `.then()` callback | no | | Inside a synchronous helper called before the `await` | yes | | In `watch()`'s source getter | yes (the source is always synchronous) | Responses that come back in the wrong order are a different problem, solved with the watcher's cleanup registration rather than with tracking.
- Does Vue 3 warn when a watchEffect() reads a ref after an await?No. From Vue's side the read is just a reactive access with no active effect, which is legal - it happens all the time in event handlers. Nothing marks it as a mistake, which is why this bug usually surfaces as 'changing X does nothing' rather than as a console message.
- Is reading a ref inside a synchronous helper called from a Vue 3 watchEffect() tracked?Yes, as long as the helper runs before the first `await`. Tracking follows the call stack of the synchronous run, not the source text of the effect function, so a helper that reads `lang.value` registers it against the running effect.
saying these in an interview costs you the question
- watchEffect() tracks every read in the function body, whatever its timing.
- Vue warns in the console when a read after an await goes untracked.
- Adding deep: true makes watchEffect() track reads after an await.
- Switching to flush: 'sync' restores tracking in async effects.
- watch() has the same problem, because its callback is async too.