Composables

useScript

Load third-party scripts with SSR support and a proxied API.

Stable as of v1.9

Features

  • 🪨 Turn a third-party script into a fully typed API
  • ☕ Delay loading your scripts until you need them: manual or Promise
  • 🚀 Best performance and privacy defaults
  • 🎃 Easily hook into script events: onload, onerror, etc
  • 🪝 Proxy API: Use a scripts functions before it's loaded (or while SSR)
  • 🇹 Fully typed APIs

Background

Loading scripts using the useHead composable is easy.

Google Analytics
useHead({
  script: [
    // Google Analytics Setup
    { innerHTML: `window.dataLayer = window.dataLayer || [], window.gtag = function gtag(...p) { window.dataLayer.push(p) }, window.gtag('js', new Date()), window.gtag('config', options.id);` },
    // Load the script
    { src: 'https://www.googletagmanager.com/gtm.js?id=GTM-MNJD4B' }
  ]
})

However, when loading a third-party script, you often want to access some functionality provided by the script.

For example, Google Analytics provides a gtag function that you can use to track events.

// We need to load first: https://www.google-analytics.com/analytics.js
gtag('event', 'page_view', {
  page_title: 'Home',
  page_location: 'https://example.com',
  page_path: '/',
})

The API provided by these scripts doesn't work in an SSR environment or if the script isn't loaded yet. Leading to a jumbled mess of trying to make sure we can use the API. For TypeScript you'll need to augment global window types to use the API effectively.

The useScript composable aims to solve these issues and more with the goal of making third-party scripts a breeze to use.

const googleAnalytics = useScript('https://www.google-analytics.com/analytics.js', {
  beforeInit() {
    window.dataLayer = window.dataLayer || []
    window.dataLayer.push('js', new Date())
    window.dataLayer.push('config', options.id)
  },
  use() { return { dataLayer: window.dataLayer } }
})
// fully typed, usable in SSR and when lazy loaded
googleAnalytics.proxy.dataLayer.push('event', 'page_view', {
  page_title: 'Home',
  page_location: 'https://example.com',
  page_path: '/',
})

declare global {
  interface Window {
    dataLayer: any[]
  }
}

Usage

referrerpolicy and crossorigin

The useScript composable is optimized for end user privacy and security.

By default, the referrerpolicy is set to no-referrer and crossorigin is set to anonymous.

Some scripts will not run correctly with these settings. If you find yourself having CORS or behavior issues, you should disable these defaults.

const instance = useScript({
  src: 'https://example.com/my-awesome-script.js',
  // setting these to false will revert the defaults
  referrerpolicy: false,
  crossorigin: false,
}, {
  use() {
    return window.myAwesomeScript
  }
})

Script Deduping

By default, your scripts will be deduped based on the script src.

const instance = useScript('/my-script.js')
const instance2 = useScript('/my-script.js')
// instance2 will return the same reference as instance without loading a new script

In cases where the src is dynamic and you're using it in multiple places, you should provide a key to the script options.

const instance = useScript({ key: 'my-script', src: '/123.js' })
const instance2 = useScript({ key: 'my-script', src: '/456.js' })
// instance2 will return the same reference as instance without loading a new script

Triggering Script Load

The trigger option is used to control when the script is loaded by the browser.

It can be one of the following:

  • undefined | client: Script tag will be inserted as the useScript is hydrated on the client side. The script will be usable once the network request is complete.
  • manual: Load the script manually using the load() function. Only runs on the client.
  • Promise: Load the script when the promise resolves. This allows you to load the script after a certain time or event, for example on the requestIdleCallback hook. Only runs on the client.
  • Function: Load the script when the function is called. Only runs on the client.
  • server: Insert the script tag into the SSR HTML response (<script src="...">).

When you're using a trigger that isn't server, the script will not exist within your SSR response, meaning it will only load client-side.

Manual
const { load } = useScript('/script.js', {
  trigger: 'manual'
})
// ...
load((instance) => {
  // use the script instance
})

Waiting for Script Load

To use the underlying API exposed by a script, it's recommended to use the onLoaded function, which accepts a callback function once the script is loaded.

Vanilla
const { onLoaded } = useScript('/script.js')
onLoaded(() => {
  // script ready!
})
Vue
const { onLoaded } = useScript('/script.js')
onLoaded(() => {
  // script ready!
})

If you have registered your script using a manual trigger, then you can call load() with the same syntax.

const { load } = useScript('/script.js', {
  trigger: 'manual'
})
load((instance) => {
  // runs once the script loads
})

The onLoaded function returns a function that you can use to dispose of the callback. For reactive integrations such as Vue, this will automatically bind to the scope lifecycle.

Vanilla
const { onLoaded } = useScript('/script.js')
const dispose = onLoaded(() => {
  // script ready!
})
// ...
dispose() // nevermind!
Vue
const { onLoaded } = useScript('/script.js')

onLoaded(() => {
  // this will never be called once the scope unmounts
})

If you just need to call a function on a script once it's loaded and don't care about the result, you may consider using the Proxy API instead.

Removing a Script

When you're done with a script, you can remove it from the document using the remove() function.

const myScript = useScript('/script.js')

myScript.remove()

The remove() function will return a boolean indicating if the script was removed in the case where the script has already been removed.

Script Loading Errors

Sometimes scripts just won't load, this can be due to network issues, browser extensions blocking the script or many other reasons.

As the script instance is a native promise, you can use the .catch() function.

const myScript = useScript('/script.js')
  .onError((err) => {
    console.error('Failed to load script', err)
  })

Otherwise, you always check the status of the script using status.

Vanilla
const myScript = useScript('/script.js')
myScript.status // 'awaitingLoad' | 'loading' | 'loaded' | 'error'
Vue
const myScript = useScript('/script.js')
myScript.status // Ref<'awaitingLoad' | 'loading' | 'loaded' | 'error'>

Proxy API

A proxy object is accessible on the script instance which provides a consistent interface for calling script functions regardless of the script being loaded.

This can be useful in instances where you don't care when the function is called or what it returns.

declare global {
  interface Window {
    analytics: {
      event: ((arg: string) => void)
    }
  }
}

const analytics = useScript('/analytics.js', {
  use() { return window.analytics }
})
// send an event if or when the script is loaded
analytics.proxy.event('foo') // void

Using the proxy API will noop in SSR, is stubbable and is future-proofed to call functions of scripts through web workers.

It's important to know when to and not to use the proxy API, it should not be used for accessing properties or when you need to know the return of the function.

declare global {
  interface Window {
    analytics: {
      event: ((arg: string) => void)
      siteId: number
      loadUser: () => { id: string }
    }
  }
}

const analytics = useScript('/analytics.js', {
  use() { return window.analytics }
})
const val = myScript.proxy.siteId // ❌ val will be a function
const user = myScript.proxy.loadUser() // ❌ the result of calling any function is always void

Stubbing

In cases where you're using the Proxy API, you can additionally hook into the resolving of the proxy using the stub option.

For example, in a server context, we probably want to polyfill some returns so our scrits remains functional.

const analytics = useScript<{ event: ((arg: string) => boolean) }>('/analytics.js', {
  use() { return window.analytics },
  stub() {
    if (import.meta.server) {
      return {
        event: (e) => {
          console.log('event', e)
        }
      }
    }
  }
})

API

useScript<API>(scriptOptions, options)

Argument: Script Options

The script options, this is the same as the script option for useHead. For example src, async, etc.

A shorthand for the src option is also available where you can just provide the URL as a string.

Simple
useScript('https://www.google-analytics.com/analytics.js')
Object
useScript({
  key: 'google-analytics', // custom key
  src: 'https://www.google-analytics.com/analytics.js',
  async: true,
  defer: true,
})

Argument: Use Script Options

trigger

  • Type: 'undefined' | 'manual' | 'server' | 'client' | Promise<void>
  • Additional Vue Types: Ref<boolean>

A strategy to use for when the script should be loaded. Defaults to client.

Promise
useScript({
  src: 'https://example.com/script.js',
}, {
  trigger: new Promise((resolve) => {
    setTimeout(resolve, 10000) // load after 10 seconds
  })
})

When server is set as the trigger, the script will be injected into the SSR HTML response, allowing for quicker loading of the script.

The client trigger ensures that the script is only loaded when the script is hydrated on the client-side.

use

  • Type: () => API

A function that resolves the scripts API. This is only called client-side.

const fathom = useScript<FathomApi>({
  // fathom analytics
  src: 'https://cdn.usefathom.com/script.js',
}, {
  use: () => window.fathom
})
fathom.then((api) => {
  // api is equal to window.fathom
})

stub

A more advanced function used to stub out the logic of the API. This will be called on the server and client.

This is particularly useful when the API you want to use is a primitive and you need to access it on the server. For instance, pushing to dataLayer when using Google Tag Manager.

const myScript = useScript<MyScriptApi>({
  src: 'https://example.com/script.js',
}, {
  use: () => window.myScript,
  stub: ({ fn }) => {
    // stub out behavior on server
    if (process.server && fn === 'sendEvent')
      return (opt: string) => fetch('https://api.example.com/event', { method: 'POST', body: opt })
  }
})
const { sendEvent, doSomething } = myScript.proxy
// on server, will send a fetch to https://api.example.com/event
// on client it falls back to the real API
sendEvent('event')
// on server, will noop
// on client it falls back to the real API
doSomething()

Script Instance API

The useScript composable returns the script instance that you can use to interact with the script.

id

The unique ID of the script instance.

status

The status of the script. Can be one of the following: 'awaitingLoad' | 'loading' | 'loaded' | 'error'

In Vue, this is a Ref.

onLoaded(cb: (instance: ReturnType) => void | Promise): () => void

A function that is called when the script is loaded. This is useful when you want to access the script directly.

const myScript = useScript('/script.js')
myScript.onLoaded(() => {
  // ready
})

then(cb: (instance: ReturnType) => void | Promise)

A function that is called when the script is loaded. This is useful when you want to access the script directly.

const myScript = useScript('/script.js')
myScript.onLoaded(() => {
  // ready
})

load(callback?: (instance: ReturnType) => void | Promise): Promise<ReturnType>

Trigger the script to load. This is useful when using the manual loading strategy.

const { load } = useScript('/script.js', {
  trigger: 'manual'
})
// ...
load()

You can optionally provide a callback function to run once the script is loaded, this is recommended over using load() as a promise.

const { load } = useScript('/script.js', {
  trigger: 'manual'
})
load(() => {

})

remove()

Remove the script from the document.

proxy

The proxy API for calling the script functions. See the Proxy API for further details.

const myScript = useScript<MyScriptApi>('/script.js', {
  use() { return window.myScript }
})
myScript.proxy.myFunction('hello')

instance

Internal value providing the use() function, this will be the result. This is passed when resolving the script using then() or load().

const myScript = useScript<MyScriptApi>('/script.js', {
  use() { return window.myScript }
})
myScript.instance // window.myScript

entry

The internal head entry for the script. This is useful for debugging and tracking the script.

const myScript = useScript('/script.js')
myScript.entry // ReturnType<typeof useHead>

Examples

CloudFlare Analytics

Unhead
import { useScript } from 'unhead'

interface CloudflareAnalyticsApi {
  __cfBeacon: {
    load: 'single'
    spa: boolean
    token: string
  }
  __cfRl?: unknown
}

declare global {
  interface Window extends CloudflareAnalyticsApi {}
}

export function useCloudflareAnalytics() {
  return useScript<CloudflareAnalyticsApi>({
    'src': 'https://static.cloudflareinsights.com/beacon.min.js',
    'data-cf-beacon': JSON.stringify({ token: 'my-token', spa: true }),
  }, {
    use() {
      return { __cfBeacon: window.__cfBeacon, __cfRl: window.__cfRl }
    },
  })
}

Fathom Analytics

Unhead
import { useScript } from 'unhead'

interface FathomAnalyticsApi {
  trackPageview: (ctx?: { url: string, referrer?: string }) => void
  trackGoal: (eventName: string, value?: { _value: number }) => void
}

declare global {
  interface Window { fathom: FathomAnalyticsApi }
}

export function useFathomAnalytics() {
  return useScript<FathomAnalyticsApi>({
    'src': 'https://cdn.usefathom.com/script.js',
    'data-site': 'my-site',
    // See https://usefathom.com/docs/script/script-advanced
  }, {
    use: () => window.fathom,
  })
}

Google Analytics

Unhead
import { useScript } from 'unhead'

interface GoogleAnalyticsApi {
  gtag: ((fn: 'event', opt: string, opt2: { [key: string]: string }) => void)
}

declare global {
  interface Window extends GoogleAnalyticsApi {}
}

export function useGoogleAnalytics() {
  return useScript<GoogleAnalyticsApi>({
    src: 'https://www.google-analytics.com/analytics.js',
  }, {
    use: () => ({ gtag: window.gtag })
  })
}