Understanding Async Context and useHead()
Introduction
This injection pattern is fundamental to how Vue manages component state and dependencies in the Composition API.
When we call useHead()
, behind the scenes, it's calling the Vue inject function to get the Unhead instance
attached to the Vue instance.
import { inject } from 'vue'
function useHead(input) {
const head = inject(HEAD_KEY)
head.push(input)
}
function injectHead() {
return inject(HEAD_KEY)
}
The inject()
function keeps track of your Vue component instance, however, after async operations within lifecycle hooks or nested functions, Vue loses track of this context.
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { onMounted } from 'vue'
onMounted(async () => {
await someAsyncOperation()
// This will throw an error
useHead({
title: 'My Title'
})
})
</script>
When trying to inject once Vue has lost the context, you'll receive an error from Unhead:
useHead() was called without provide context.
We'll look at how we can prevent this error and ensure our head updates work correctly across async operations.
Use Top Level Await
Vue's script setup handles async operations through compile-time transforms that preserve the component instance context. As explained in Anthony's article on async composition, this makes most async operations work seamlessly.
At the top level of script setup, context is automatically preserved:
<script setup lang="ts">
import { useHead } from '@unhead/vue'
// The compiler transforms this to preserve context
await someAsyncOperation()
useHead({
title: 'My Title'
})
</script>
This is the simplest and most effective way to handle async operations in Vue.
effectScope()
Using
The effectScope()
API allows you to re-run a code block using the same component context, making it ideal for solving
the async context loss issue.
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { effectScope, onMounted } from 'vue'
// Create an effect scope before any async operations
const scope = effectScope()
onMounted(async () => {
const data = await fetchData()
// Run all effects within this scope
scope.run(() => {
// All composables inside here will be properly cleaned up
useHead({
title: data.title,
meta: [
{
name: 'description',
content: data.description
}
]
})
})
})
</script>
injectHead()
Using
The injectHead()
function lets us grab a reference to Unhead's instance before any async operations occur.
Here's how to use it effectively:
<script setup lang="ts">
import { injectHead, onMounted } from '@unhead/vue'
// Store the head instance at setup time
const head = injectHead()
// For simple updates, we don't need to pass the head instance
useHead({
title: 'My Site'
})
// Inside async functions, we must pass the head instance
async function updatePageHead(id: string) {
const data = await fetchPage(id)
// Pass head as an option to maintain context
useHead({
title: data.title,
meta: [
{
name: 'description',
content: data.description
}
]
}, { head }) // The head instance ensures the update works
}
// The same applies for lifecycle hooks
onMounted(async () => {
const analyticsData = await loadAnalytics()
useHead({
script: [
{
// Analytics script injection after load
children: analyticsData.scriptContent
}
]
}, { head })
})
</script>
The key idea is that injectHead()
should be called at the top level of your component, before any async operations. This ensures you have a stable reference to use throughout your component's lifecycle.
Using Reactive State
A more elegant way to handle async head updates is to combine reactive state with useHead. This approach lets you define your head configuration once and have it automatically update as your data changes:
<script setup lang="ts">
import { computed, ref } from 'vue'
// Initialize your reactive state
const page = ref({
title: 'Loading...',
description: '',
image: '/placeholder.jpg'
})
// Define head once with computed properties
useHead({
// Title will automatically update when page.value.title changes
title: computed(() => page.value.title),
meta: [
{
name: 'description',
content: computed(() => page.value.description)
},
{
property: 'og:image',
content: computed(() => page.value.image)
}
]
})
// Your async operations just update the reactive state
async function loadPage(id: string) {
const data = await fetchPage(id)
// Head updates automatically when we update the ref
page.value = {
title: data.title,
description: data.description,
image: data.image
}
}
// Works great with watchers too
watch(route, async () => {
await loadPage(route.params.id)
})
</script>
Pinia Store
You can also use this pattern with more complex state management:
<script setup lang="ts">
import { usePageStore } from '@/stores/page'
import { storeToRefs } from 'pinia'
const store = usePageStore()
// Destructure with storeToRefs to maintain reactivity
const { title, description } = storeToRefs(store)
useHead({
title, // Reactive store state automatically works
meta: [
{
name: 'description',
content: description
}
]
})
// Now your store actions can update the head
await store.fetchPage(id)
</script>
This reactive state pattern aligns well with Vue's Composition API design and often results in cleaner, more maintainable code than manually updating the head after each async operation.
Async Context in Nuxt
When using Nuxt, you don't need to worry about managing async context for head updates. This is because Nuxt attaches Unhead directly to the Nuxt application instance which is globally accessible regardless of the async operation.
This decision allows you to use useHead()
anywhere, including plugins, middleware and layouts without worrying about context.
Learn More
The Vue team's solution for async context through script setup transforms is quite elegant. You can read more about the technical implementation in the Script Setup RFC. For a deeper understanding of how async context evolved in Vue's Composition API, check out Anthony Fu's detailed exploration of the topic.