Hooks

useScript

An on-demand external script loader hook that reports idle, loading, ready or error and keeps exactly one deduped tag per src across every caller.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * 一个 `src` 的加载状态。`idle` = 这一刻根本没在加载:`enabled: false`、`src`
 * 为空、SSR,以及客户端的第一帧——标签是在 effect 里注入的,渲染期不碰 DOM。
 */
export type ScriptStatus = "idle" | "loading" | "ready" | "error"

/** 已经有定论、不会再变的两个终态。 */
type SettledStatus = "ready" | "error"

export interface UseScriptOptions {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-script.json

Prompt

Build a React + TypeScript "useScript" hook (React only, no npm dependencies —
it drives the DOM's own <script> element plus useSyncExternalStore).

Contract
- `useScript(src: string | null | undefined, options?: UseScriptOptions):
  ScriptStatus`, where `ScriptStatus = "idle" | "loading" | "ready" | "error"`.
  The return value IS the status string; consumers branch on it.
- `UseScriptOptions`: `enabled` (default true), `async` (default true),
  `defer` (default false), `crossOrigin`, `integrity`, `referrerPolicy`,
  `type`, `nonce`, `id`, `attributes` (Record<string, string> for vendor
  `data-*` requirements), `removeOnUnmount` (default false).
- State lives at module scope, not in the component: a
  `Map<src, ScriptRecord>` where `ScriptRecord = { element, status, consumers,
  pinned, owned, detach }`, plus a `Map<src, Set<listener>>` for subscribers.
  Every caller of the same `src` reads and writes the same record — that is
  what makes "one tag, one execution, one shared status" true rather than
  aspirational.
- React sees that store through `useSyncExternalStore(subscribe, getSnapshot,
  getServerSnapshot)`: `getSnapshot` returns the record's status ("idle" when
  there is no record), `getServerSnapshot` always returns "idle".

Behavior
- Key: `const key = enabled && src ? src : null`. A null key means no
  subscription, no tag, no request — the status stays "idle". `enabled` is the
  entire on-demand lever (load the payment SDK only once checkout opens).
- Injection happens in a mount effect keyed on `key` — never during render:
  `acquire(key)` on setup, `release(key)` on cleanup. Render must stay pure so
  a concurrent render that gets thrown away cannot leave a tag behind.
- acquire(src):
  1. Look up the record. If absent, create one:
     a. Look for a `<script>` already in the document whose `src` ATTRIBUTE
        equals `src` — iterate `document.querySelectorAll("script[src]")` and
        string-compare `getAttribute("src")`, do NOT build a
        `script[src="..."]` selector: the URL is external input and a quote in
        it breaks (or redirects) the selector. If found, adopt it and set
        `owned: false`.
     b. Read `data-script-status` off the element. "ready"/"error" means an
        earlier instance already settled it: start settled and attach no
        listeners. Otherwise start "loading" and attach the `load`/`error`
        listeners BEFORE insertion — a script starts fetching the moment it is
        connected to the document.
     c. If nothing was adopted, write the consumer's `attributes` FIRST and the
        managed properties second (crossOrigin, integrity, referrerPolicy,
        type, nonce, id, then async, defer, src,
        `data-script-status="loading"`) so `attributes` can never rewrite the
        `src` out from under the dedupe key; then append to `document.head`.
  2. `record.consumers += 1`.
  3. `if (options.removeOnUnmount !== true) record.pinned = true` — a single
     consumer that expects the tag to outlive it pins the tag for good.
  4. If the record was just created, notify subscribers: the status moved from
     "idle" to "loading" (or straight to a settled value).
- settle(src, "ready" | "error") is called from the tag's own load/error
  handler: bail unless the record is still "loading" (idempotent, and adopted
  tags may already be settled), write the status, mirror it onto
  `data-script-status`, detach BOTH listeners at once — neither can fire again
  — then notify every subscriber.
- release(src): `record.consumers -= 1`, then REFUSE to remove the tag if any
  of three conditions holds — `consumers > 0` (someone still depends on it),
  `pinned` (some consumer never asked for removal), or `!owned` (the tag was
  hand-written or injected by another library; never delete someone else's
  node). Only when all three clear: detach, `element.remove()`, delete the
  record so a future mount re-injects from scratch.
- Removal is opt-in and the docs must explain why it is not the default:
  (1) removing the element does not un-run the file — the globals it defined,
  the listeners it bound, the timers it started and the styles it injected all
  survive, so removal buys the appearance of a clean slate, not the thing;
  (2) re-mounting re-injects and therefore re-EXECUTES the file, and a
  non-idempotent vendor SDK that registers twice is a real, hard-to-trace bug;
  (3) StrictMode and fast refresh simulate mount → unmount → mount, so that
  double execution would happen on every development mount.
- Failure is sticky by design: an "error" record stays in the map with the
  failed tag still in the document, so every later caller of that URL renders
  "error" on its first frame instead of a stampede of components retrying a
  dead address. Retrying is expressed as loading a different URL (a
  cache-busting query string) — that is a different key.
- Changing `src`, or flipping `enabled`, tears down the old subscription and
  releases the old record before subscribing to and acquiring the new one. The
  hook keeps no per-component copy of the status that could drift.
- Options that are only read at injection time (`attributes`,
  `removeOnUnmount`, the tag properties) go through a latest-ref written in an
  insertion effect, NOT into the effect's dependency array: callers write
  `{ attributes: { ... } }` inline every render, and depending on that object
  would release + acquire on every render.
- Degenerate cases to get right: `src = null` or `""` → "idle", zero work;
  SSR → "idle" with the DOM untouched; an adopted tag carrying no
  `data-script-status` may already have finished, and a load event that has
  already fired is never replayed — warn in development and document
  `data-script-status="ready"` as the escape hatch for hand-written tags;
  `attributes` are honoured only for the FIRST caller of a given `src`,
  because the tag is created exactly once; `defer` is inert on injected
  scripts (it only means anything for parser-inserted ones) — keep it for
  attribute parity with hand-written tags and say so out loud; `integrity`
  mismatch and a CSP block both surface as plain "error", like a 404.
- Cleanup checklist: load/error listeners detached on settle AND on removal;
  the subscriber Set entry deleted when its last listener leaves; the effect's
  `release` runs on unmount and on every key change; no timers, no observers,
  nothing left running.

Rendering & styling
- The hook renders nothing and owns no markup. Consumers map status to UI with
  semantic tokens only: `bg-muted` / `text-muted-foreground` for idle, a
  skeleton or a spinner carrying `motion-reduce:animate-none` for loading,
  `bg-card` / `bg-primary` for the live widget, `text-destructive` for error —
  all merged through `cn()`.
- ARIA: wrap the status readout in `role="status" aria-live="polite"` so the
  flip to ready/error is announced exactly once, and give the error branch
  real text, not just a colour.
- Keyboard: the hook adds no key handling of its own. A trigger that starts an
  on-demand load stays an ordinary button (Enter/Space); while it is loading
  mark it `aria-disabled` plus a guard clause in the handler rather than the
  native `disabled` attribute, which blurs the control the user is standing on
  and drops focus to <body>.
- Never gate content on "ready" without an error branch: a blocked, offline or
  ad-blocked third-party script is the common case, not the exotic one.

Customization levers
- `enabled` is the whole on-demand story: gate it on a click, an
  IntersectionObserver (load when the section scrolls into view), an idle
  callback, or a cookie-consent decision.
- `attributes` carries per-vendor requirements (`data-site-id`,
  `data-cf-beacon`) without widening the option type.
- Change what the key is: `src` alone (one tag per URL — the default) or
  `src + a variant id` if the same file genuinely must be able to run twice.
- Add an `onReady` callback if you would rather imperatively init the SDK than
  branch on the status in render — keep it in a latest-ref so an inline arrow
  does not re-run the effect.
- The removal policy is the one knob to decide before shipping: keep the
  default (never remove) for anything that installs globals;
  `removeOnUnmount: true` only for a script you actually want re-executed and
  whose side effects nothing else reads.

Concepts

  • Dedupe by src — the identity of a script is its URL, so the registry is keyed on the URL and not on the component. The fifth component that asks for the same SDK does not get a fifth tag, a fifth download or a fifth execution; it gets the record that already exists, which is why a late caller renders ready on its very first frame instead of flashing loading and correcting itself.
  • Status as an external store — the truth is "what is in the document", which no single component owns, so it lives in a module-level map and reaches React through useSyncExternalStore (the same shape use-online uses for online/offline). Component state would fork into N copies that have to be kept in sync by hand.
  • Ref count + pin = the refusal — teardown asks three questions: is anyone else still mounted, did anyone ever say they wanted this tag to outlive them, and did we even create this tag? Any single "yes" refuses the removal. The last component to unmount does not get to speak for the rest of the app.
  • Executing is not undoable — this is why removeOnUnmount defaults to false. Deleting the element cannot recall the globals, listeners, timers and styles the file already installed; all it guarantees is that re-mounting will run the file a second time. StrictMode's mount → unmount → mount turns that into a per-mount event during development.
  • Sticky failure — a dead URL is recorded as error and stays that way, so N components asking for a broken script produce one request and one error, not N retries. Retrying is spelled "load a different URL", which is honest: a new cache-busting query string is a new key and a new tag.
  • data-script-status as a handshake — the record can be lost (a second bundle copy of the hook, a page that shipped the tag in its HTML) while the tag survives. Mirroring the status onto the element makes the DOM the durable channel: an adopted tag that says ready is trusted immediately, and a hand-written tag can opt into the same trust by carrying that attribute.

On This Page