Composables

useScript() manages an external script through an Unhead instance. It deduplicates repeated calls, controls when the script is inserted, and can expose the API created by the third-party SDK.

import { useScript } from '@unhead/vue'

interface AnalyticsApi {
  track: (event: string) => void
}

const analytics = useScript<AnalyticsApi>('https://example.com/analytics.js', {
  use: () => window.analytics,
})

// Recorded now and replayed after the API resolves.
analytics.proxy.track('pageview')

The proxy is created only when you provide use or resolve. Without either option, use load(), onLoaded(), and the other controller methods directly.

Script identity and defaults

Scripts are shared by key or src within one Unhead instance. A second call with the same identity returns the cached script controller; it does not add another <script> element.

When loading, Unhead applies these defaults unless the input overrides them:

  • defer
  • fetchpriority="low"
  • crossorigin="anonymous" and referrerpolicy="no-referrer" for absolute HTTP(S) or protocol-relative URLs

Framework integrations normally wait for their client mount lifecycle before triggering the load. The core client API loads immediately when its trigger is omitted.

useScript({
  src: 'https://example.com/widget.js',
  async: true,
  defer: false,
  crossorigin: false,
})

Loading triggers

The trigger option accepts:

ValueBehavior
undefined or 'client'Load on the client; framework wrappers may defer this until mount
'server'Insert the script during SSR
'manual' or nullWait for script.load()
Promise<boolean | void>Load after it resolves to true or undefined; false leaves the script unloaded
(load) => cleanup?Let application code call load; an optional returned function cleans up the trigger
const script = useScript('https://example.com/widget.js', {
  trigger: 'manual',
})

await script.load()

A function trigger can bind to user interaction:

useScript('https://example.com/video-player.js', {
  trigger: (load) => {
    const button = document.querySelector('#play')
    button?.addEventListener('click', load, { once: true })
    return () => button?.removeEventListener('click', load)
  },
})

The function cleanup runs when loading begins, when its returned disposer is called, or when the shared script is removed. Promise and function triggers run only on the client.

Vue additionally accepts a Ref<boolean> or zero-argument boolean getter as a trigger.

Reusable triggers

Browser trigger helpers live in a separate, tree-shakable entry:

import { createScriptTriggerInteraction } from 'unhead/scripts/triggers'

useScript('https://example.com/widget.js', {
  trigger: createScriptTriggerInteraction({
    events: ['pointerdown', 'keydown'],
  }),
})

The entry also exports createScriptTriggerTimeout() and createScriptTriggerServiceWorker().

Resolving an SDK API

Use use when the script's API is available as soon as the element's load event fires:

const widget = useScript('https://example.com/widget.js', {
  use: () => window.Widget,
})

widget.onLoaded((api) => {
  api.mount('#widget')
})

Some SDKs signal readiness later. Use resolve with waitFor() to keep load() and onLoaded() pending until that callback fires:

const sdk = useScript('https://example.com/sdk.js', {
  resolve: ({ waitFor }) => waitFor<typeof window.ExternalSDK>((resolve) => {
    const onReady = () => resolve(window.ExternalSDK)
    window.addEventListener('external-sdk:ready', onReady, { once: true })
    return () => window.removeEventListener('external-sdk:ready', onReady)
  }),
})

const api = await sdk.load()
if (!api)
  throw new Error('The SDK did not load')

The resolve context also provides an AbortSignal. It is aborted if loading fails or the script is removed. A rejected resolver changes the script status to error. At runtime, load() resolves to false after an error, removal, or lifecycle abort; the current public TypeScript declaration narrows that result to T.

Source-less SDKs

Put loader on a keyed resource when an SDK comes from import() or another client-only transport. Unhead will not render a script or preload tag. Triggers, per-head dedupe, proxy queues, and cancellation work like URL scripts.

const script = useScript({
  key: 'example-sdk',
  loader: async ({ signal }) => {
    const module = await import('example-sdk')
    if (signal.aborted)
      throw signal.reason
    return module
  },
}, {
  trigger: 'manual',
})

const sdk = await script.load()

Dynamic imports cannot be cancelled. Check the supplied signal before SDK initialization when removal must prevent late side effects.

Proxy behavior

Before the API resolves, proxy records property access and function calls. Calls return void; they are replayed against the resolved API in order. After resolution, the proxy forwards calls directly, applying methods against their real owner so vendor receiver checks keep working.

proxy is a stable reference. Destructuring it before load is safe, including individual methods:

const { proxy } = useScript<AnalyticsApi>('https://example.com/analytics.js', {
  use: () => window.analytics,
})
const { track } = proxy // still forwards once the script loads

Use onError() for failures rather than assuming a queued call succeeded:

const analytics = useScript<AnalyticsApi>('https://example.com/analytics.js', {
  use: () => window.analytics,
})

analytics.onError((error) => {
  console.warn('Analytics did not load', error)
})
analytics.proxy.track('pageview')

Lifecycle

The controller moves through these states:

type UseScriptStatus =
  | 'awaitingLoad'
  | 'loading'
  | 'loaded'
  | 'error'
  | 'removed'

In Vue, status is a Ref<UseScriptStatus>; the core and other framework controllers expose the status value directly.

onLoaded() and onError() each return a function that unregisters the callback. An optional { key } deduplicates callbacks of the same event type.

const off = script.onLoaded((api) => {
  api.mount('#widget')
}, { key: 'mount-widget' })

off()

remove() disposes the shared head entry, cancels its triggers and warmup entry, aborts its signal, and moves it to removed. Because the script is shared, this affects every consumer of that controller.

A removed controller is terminal. Calling load() again resolves through the completed lifecycle without adding another script. Call useScript() again with the same input to create a fresh controller.

Consumer scopes

The core API and Vue integration support { scope: true }. A scope owns its callbacks and triggers while leaving the underlying script shared:

import { useScript } from 'unhead/scripts'

const scoped = useScript(head, 'https://example.com/widget.js', {
  scope: true,
  use: () => window.Widget,
})

scoped.onLoaded(api => api.mount('#widget'))

// Releases this consumer. It does not remove the shared script.
scoped.dispose()

Vue disposes an opted-in scope with its component scope. In other core integrations, call dispose() yourself. Call remove() only when you intend to remove the shared script for every consumer.

Resource hints

The core option and controller support three warmup strategies:

type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch'
const script = useScript('https://example.com/widget.js', {
  trigger: 'manual',
})

script.warmup('preconnect')

preload targets the full script URL. preconnect and dns-prefetch target the origin and are skipped for root-relative URLs. Call warmup() once per controller; the controller tracks only its most recently created warmup entry for later cleanup. At runtime, warmup() returns undefined when no hint is registered, although the current public declaration is narrower.

Controller API

interface ScriptInstance<T> {
  id: string
  status: UseScriptStatus
  signal: AbortSignal
  instance?: T
  proxy: AsVoidFunctions<T>
  load: () => Promise<T>
  warmup: (strategy: WarmupStrategy) => ActiveHeadEntry
  remove: () => boolean
  onLoaded: (callback: (api: T) => void, options?: { key?: string }) => () => void
  onError: (callback: (error?: Error) => void, options?: { key?: string }) => () => void
  setupTriggerHandler: (trigger: UseScriptOptions['trigger']) => () => void
}

For the core unhead/scripts export, pass the head instance as the first argument. Framework packages inject their current head instance, so their composable signature is useScript(input, options?).

Did this page help you?