---
title: "useScript()"
description: "Load and share third-party scripts with lifecycle callbacks, loading triggers, API resolution, and optional call proxying."
canonical_url: "https://unhead.unjs.io/docs/head/api/composables/use-script"
last_updated: "2026-07-28T14:52:25.523Z"
---

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

```ts
import { useScript } from '@unhead/dynamic-import'

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.

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

## Loading triggers

The `trigger` option accepts:

<table>
<thead>
  <tr>
    <th>
      Value
    </th>
    
    <th>
      Behavior
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        undefined
      </code>
      
       or <code>
        'client'
      </code>
    </td>
    
    <td>
      Load on the client; framework wrappers may defer this until mount
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        'server'
      </code>
    </td>
    
    <td>
      Insert the script during SSR
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        'manual'
      </code>
      
       or <code>
        null
      </code>
    </td>
    
    <td>
      Wait for <code>
        script.load()
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        Promise<boolean | void>
      </code>
    </td>
    
    <td>
      Load after it resolves to <code>
        true
      </code>
      
       or <code>
        undefined
      </code>
      
      ; <code>
        false
      </code>
      
       leaves the script unloaded
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        (load) => cleanup?
      </code>
    </td>
    
    <td>
      Let application code call <code>
        load
      </code>
      
      ; an optional returned function cleans up the trigger
    </td>
  </tr>
</tbody>
</table>

```ts
const script = useScript('https://example.com/widget.js', {
  trigger: 'manual',
})

await script.load()
```

A function trigger can bind to user interaction:

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

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

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

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

### 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.

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

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

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

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

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

```ts
type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch'
```

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

```ts
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?)`.
