useScript()
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:
deferfetchpriority="low"crossorigin="anonymous"andreferrerpolicy="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:
| Value | Behavior |
|---|---|
undefined or 'client' | Load on the client; framework wrappers may defer this until mount |
'server' | Insert the script during SSR |
'manual' or null | Wait 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?).