---
title: "Vue Reactivity and Async Context"
description: "Use refs, computed, and Pinia with useHead(). Preserve the head instance when updating tags after asynchronous work."
canonical_url: "https://unhead.unjs.io/docs/vue/head/guides/core-concepts/reactivity-and-context"
last_updated: "2026-08-11T00:47:04.155Z"
---

Pass refs and computed values directly to `useHead()`; Vue tracks them for you. Capture `injectHead()` before asynchronous work when a later call may run outside component context.

## Vue Integration

`useHead()` accepts refs, computed refs, and getter functions. The adapter resolves them in a `watchEffect()` and patches the same entry when a dependency changes.

Unhead uses Vue's [provide/inject system](https://vuejs.org/api/composition-api-dependency-injection.html#inject) to retrieve the instance registered on the application.

### Component Lifecycle

The adapter also follows Vue's component lifecycle:

- When a component is unmounted, any head entries created by that component are automatically removed
- When a component is deactivated with keep-alive, its entries will be deactivated
- When a component is activated with keep-alive, its entries will be reactivated

### Client and Server Reactivity

Reactivity behaves differently depending on the rendering context:

- **Server-Side Rendering (SSR)**: Values are resolved only when the tags are being rendered, usually after the app has finished rendering.
- **Client-Side Rendering (CSR)**: Any ref changes trigger a DOM update, making the head tags reactive after hydration.

## Reactive Values

Unhead works with all Vue reactive primitives:

```ts
import { useHead } from '@unhead/vue'
import { computed, ref } from 'vue'

// Create reactive state
const title = ref('My Site')
const description = ref('Welcome to my website')
const product = ref({ name: 'widget' })

// Use reactive values in head tags
useHead({
  // Direct ref
  title,
  meta: [
    // Computed getter (recommended for derived values)
    { name: 'description', content: () => description.value },
    // Using refs directly in objects
    { property: 'og:title', content: title }
  ],
  // Computed ref
  link: [computed(() => ({
    rel: 'canonical',
    href: `https://example.com/products/${product.value.name}`
  }))]
})
```

## Async Context

The `inject()` function keeps track of your Vue component instance, but after async operations within lifecycle hooks or nested functions, Vue can lose track of this context.

```vue
<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:

<warning>

useHead() was called without provide context.

</warning>

Choose the pattern that matches where the asynchronous work runs.

### Top-Level Await

Vue's `<script setup>` compiler [preserves component context across top-level `await`](https://vuejs.org/api/sfc-script-setup.html#top-level-await) through a compile-time transform.

At the top level of script setup, context is automatically preserved:

```vue
<script setup lang="ts">
import { useHead } from '@unhead/vue'

// The compiler transforms this to preserve context
await someAsyncOperation()
useHead({
  title: 'My Title'
})
</script>
```

Outside compiler-transformed top-level code, create the head entry before awaiting and patch it when the data arrives. This keeps its watcher and cleanup attached to the component scope.

### Create the Entry Before Awaiting

Create entries synchronously during setup, then patch those entries from asynchronous callbacks:

```vue
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { onMounted } from 'vue'

const pageHead = useHead({
  title: 'My Site'
})
const analyticsHead = useHead({})

async function updatePageHead(id: string) {
  const data = await fetchPage(id)

  pageHead.patch({
    title: data.title,
    meta: [
      {
        name: 'description',
        content: data.description
      }
    ]
  })
}

onMounted(async () => {
  const analyticsUrl = await loadAnalyticsUrl()

  analyticsHead.patch({
    script: [
      {
        // Only use URLs from an allowlisted, trusted origin.
        src: analyticsUrl
      }
    ]
  })
})
</script>
```

Do not place strings from APIs or users in a script's `innerHTML` or `textContent`; both execute as JavaScript. Vue's [security guide](https://vuejs.org/guide/best-practices/security#potential-dangers) recommends avoiding untrusted JavaScript and URLs.

### Reactive State

Define the head entry during setup and update reactive state when the request completes:

```vue
<script setup lang="ts">
import { useHead } from '@unhead/vue'
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)
    }
  ]
})

// Async operations 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

The same pattern works with a Pinia store:

```vue
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { storeToRefs } from 'pinia'
import { usePageStore } from '@/stores/page'

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>
```

## Common Patterns

### Refs and Computed Getters

Prefer direct refs and computed getters:

```ts
// ✅ Good approach
const title = ref('Product Page')
const product = ref({ name: 'Widget' })

useHead({
  // Direct ref
  title,
  // Computed getter
  meta: [
    { property: 'og:title', content: () => `${product.value.name} - ${title.value}` }
  ]
})
```

### Translated Titles

Call translation functions inside getters so locale changes update the existing head entry:

```ts
const { locale, t } = useI18n()

useHead({
  htmlAttrs: {
    lang: () => locale.value,
  },
  title: () => t('pages.login.title'),
  titleTemplate: title => title
    ? `${title} | ${t('brand.name')}`
    : t('brand.name'),
})
```

Calling `t()` before `useHead()` stores only its current result:

```ts
// Does not update when the locale changes
useHead({ title: t('pages.login.title') })
```

Pass a functional `titleTemplate` directly. If you wrap it in `computed()`, Vue treats its parameter as the previous computed value rather than the page title from Unhead:

```ts
// The callback parameter is not the page title
useHead({
  titleTemplate: computed(title => `${title} | ${t('brand.name')}`),
})
```

Once Unhead owns the title, avoid writing to `document.title` directly. Unhead will overwrite that value on its next render.

### Avoid Creating Entries in Watchers

Avoid `useHead()` calls in watchers, as this creates new entries on each update:

```ts
// ❌ Bad approach: Creates multiple entries
watch(title, (newTitle) => {
  useHead({
    title: newTitle
  })
})

// ✅ Good approach: Updates existing entry
useHead({
  title // ref value updates automatically
})
```

### KeepAlive

Vue's `<KeepAlive>` preserves component state across deactivation. Unhead deactivates and restores that component's entries at the same time:

```vue
<!-- KeepAlive preserves the component and its head entry. -->
<KeepAlive>
  <component :is="currentView" />
</KeepAlive>
```

## Examples

### Dynamic SEO Metadata

```ts
import { useSeoMeta } from '@unhead/vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'

export default {
  setup() {
    const route = useRoute()
    const product = ref(null)

    // Fetch data based on route
    fetchProduct(route.params.id).then((data) => {
      product.value = data
    })

    // SEO tags update automatically when product data is loaded
    useSeoMeta({
      title: () => product.value?.name || 'Loading...',
      description: () => product.value?.description || '',
      ogImage: () => product.value?.image || '/default.jpg',
    })
  }
}
```

### Multiple Entries

You can use multiple `useHead()` calls in different components, and Unhead will handle merging them correctly:

```ts
// BaseLayout.vue
useHead({
  titleTemplate: '%s | My Site',
  meta: [
    { name: 'theme-color', content: '#ff0000' }
  ]
})

// ProductPage.vue
useHead({
  title: () => product.value?.name || '',
  meta: [
    { name: 'description', content: () => product.value?.description || '' }
  ]
})
```

## Nuxt Async Context

Nuxt preserves application context across supported transformed scopes, including component setup, plugins, and middleware. Arbitrary callbacks are not automatically transformed, so the reactive-state pattern remains the safest approach when asynchronous work completes later.

<tip>

Within Nuxt-transformed setup, plugin, and middleware code, the framework restores context across `await` expressions.

</tip>

## Adapter Internals

The Vue adapter gets the head instance through provide/inject, unwraps refs and computed values in `watchEffect()`, and registers activation and cleanup hooks on the current component. Vue's [`<script setup>` API](https://vuejs.org/api/sfc-script-setup.html#top-level-await) documents the top-level await transform used in the async examples above.
