Understanding Async Context and useHead()

Manage head tags with useHead() in Vue across async operations, component lifecycles, and server-side rendering.

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.

Using effectScope()

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>

Using injectHead()

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.

Did this page help you?