There is a major caveat when working with asynchronous functions in Vue Composition API, that I believe many of you have ever come across. I have acknowledged it for a while from somewhere, but every time I want to have a detailed reference and share to others, I can’t find it’s documented anywhere. So, I am thinking about writing one, with a detailed explanation while sorting out the possible solutions for you.
The Problem #
When using asynchronous setup()
, you have to use effects and lifecycle hooks before the first await
statement. (details)
For example:
import { onMounted, onUnmounted, ref, watch } from 'vue'
export default defineAsyncComponent({
async setup() {
const counter = ref(0)
watch(counter, () => console.log(counter.value))
// OK!
onMounted(() => console.log('Mounted'))
// the await statement
await someAsyncFunction() // <-----------
// does NOT work!
onUnmounted(() => console.log('Unmounted'))
// still works, but does not auto-dispose
// after the component is destroyed (memory leak!)
watch(counter, () => console.log(counter.value * 2))
}
})
After the await
statement,
the following functions will be limited (no auto-dispose):
watch
/watchEffect
computed
effect
the following functions will not work:
onMounted
/onUnmounted
/onXXX
provide
/inject
getCurrentInstance
- …
The Mechanism #
Let’s take the onMounted
API as an example. As we know, onMounted
is a hook that registers a listener when the current component gets mounted. Notice that onMounted
(along with other composition APIs) are global, for what I mean "global" is that it can be imported and called anywhere - there is no local context bound to it.
// local: `onMounted` is a method of `component` that bound to it
component.onMounted(/* ... */)
// global: `onMounted` can be called without context
onMounted(/* ... */)
So, how does onMounted
know what component is being mounted?
Vue takes an interesting approach to solve this. It uses an internal variable to record the current component instance. There is a simplified code:
When Vue mounts a component, it stores the instance in a global variable. When hooks been called inside the setup function, it will use the global variable to get the current component instance.
let currentInstance = null
// (pseudo code)
export function mountComponent(component) {
const instance = createComponent(component)
// hold the previous instance
const prev = currentInstance
// set the instance to global
currentInstance = instance
// hooks called inside the `setup()` will have
// the `currentInstance` as the context
component.setup()
// restore the previous instance
currentInstance = prev
}
A simplified onMounted
implementation would be like:
// (pseudo code)
export function onMounted(fn) {
if (!currentInstance) {
warn(`"onMounted" can't be called outside of component setup()`)
return
}
// bound listener to the current instance
currentInstance.onMounted(fn)
}
With this approach, as long as the onMounted
is called inside the component setup()
, it will be able to get the instance of the current component.
The Limitation #
So far so good, but what’s wrong with asynchronous functions?
The implementation would work based on the fact that JavaScript is single-threaded. Single thread makes sure the following statements will be executed right next to each other, which in other words, there is no one could accidentally modify the currentInstance
at the same time (a.k.a. it’s atomic).
currentInstance = instance
component.setup()
currentInstance = prev
The situation changes when the setup()
is asynchronous. Whenever you await
a promise, you can think the engine paused the works here and went to do another task. If we await
the function, during the time period, multiple components creation will change the global variable unpredictably and end up with a mess.
currentInstance = instance
await component.setup() // atomic lost
currentInstance = prev
If we don’t use await
to check the instance, calling the setup()
function will make it finish the tasks before the first await
statement, and the rest will be executed whenever the await
statement is resolved.
async function setup() {
console.log(1)
await someAsyncFunction()
console.log(2)
}
console.log(3)
setup()
console.log(4)
// output:
3
1
4
(awaiting)
2
This means, there is no way for Vue to know when will the asynchronous part been called from the outside, so there is also no way to bound the instance to the context.
The Solutions #
This is actually a limitation of JavaScript itself, unless we have some new proposal to open the gate on the language level, we have to live with it.
But to work around it, I have collected a few solutions for you to choose from based on your needs.
Remember the Caveat and Avoid It #
This is, of course, an obvious "solution". You can try to move your effect and hooks before the first await
statement and carefully remember not to have them after that again.
Luckily, if you are using ESLint, you can have the vue/no-watch-after-await
and vue/no-lifecycle-after-await
rules from eslint-plugin-vue
enabled so it could warn you whenever you made some mistakes (they are enabled by default within the plugin presets).
Wrap the Async Function as "Reactive Sync" #
In some situations, your logic might be relying on the data that fetched asynchronously. In this way, you could consider using the trick I have shared on VueDay 2021 to turn your async function into a sync reactive state.
const data = await fetch('https://api.github.com/').then(r => r.json())
const user = data.user
const data = ref(null)
fetch('https://api.github.com/')
.then(r => r.json())
.then(res => data.value = res)
const user = computed(() => data?.user)
This approach make the "connections" between your logic to resolve first, and then reactive updates when the asynchronous function get resolved and filled with data.
There is also some more general utilities for it from VueUse:
useAsyncState
#
import { useAsyncState } from '@vueuse/core'
const { state, ready } = useAsyncState(async () => {
const { data } = await axios.get('https://api.github.com/')
return { data }
})
const user = computed(() => state?.user)
useFetch
#
import { useFetch } from '@vueuse/core'
const { data, isFetching, error } = useFetch('https://api.github.com/')
const user = computed(() => data?.user)
Explicitly Bound the Instance #
Lifecycle hooks actually accept a second argument for setting the instance explicitly.
export default defineAsyncComponent({
async setup() {
// get and hold the instance before `await`
const instance = getCurrentInstance()
await someAsyncFunction() // <-----------
onUnmounted(
() => console.log('Unmounted'),
instance // <--- pass the instance to it
)
}
})
However, the downside is that this solution does not work with watch
/ watchEffect
/ computed
/ provide
/ inject
as they does not accept the instance argument.
To get the effects work, you could use the effectScope
API in the upcoming Vue 3.2.
import { effectScope } from 'vue'
export default defineAsyncComponent({
async setup() {
// create the scope before `await`, so it will be bond to the instance
const scope = effectScope()
const data = await someAsyncFunction() // <-----------
scope.run(() => {
/* Use `computed`, `watch`, etc. ... */
})
// the lifecycle hooks will not be available here,
// you will need to combine it with the previous snippet
// to have both lifecycle hooks and effects works.
}
})
Compile-time Magic! #
In the recent <script setup>
proposal update, a new compile-time magic is introduced.
The way it works is to inject a script after each await
statement for restoring the current instance state.
<script setup>
const post = await fetch(`/api/post/1`).then((r) => r.json())
</script>
import { withAsyncContext } from 'vue'
export default {
async setup() {
let __temp, __restore
const post
= (([__temp, __restore] = withAsyncContext(() =>
fetch(`/api/post/1`).then(r => r.json())
)),
(__temp = await __temp),
__restore(),
__temp)
// current instance context preserved
// e.g. onMounted() will still work.
return { post }
}
}
With it, the async functions will just work when using with <script setup>
. The only shame is it does not work outside of <script setup>
.