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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-script.jsonPrompt
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 rendersreadyon its very first frame instead of flashingloadingand 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 shapeuse-onlineuses foronline/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
removeOnUnmountdefaults 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
errorand 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-statusas 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 saysreadyis trusted immediately, and a hand-written tag can opt into the same trust by carrying that attribute.
useRaf
A requestAnimationFrame loop hook that hands every frame a delta/elapsed payload, with start/stop controls, an fps cap and automatic hidden-tab suspension.
useFocusWithin
A callback-ref hook that reports whether focus is inside a subtree — no flicker when focus moves between children, and it still flips off when the focused node is removed.