Third-party widgets — Stripe, a maps SDK, an analytics snippet — ship as a <script src> you're supposed to drop in the page <head>. In a SPA you often want to load them lazily, only on the route that needs them, and know when they're ready so you can safely touch their globals. useScript does that: give it a URL, it injects the tag once and returns a status you can render against.
Implement useScript(src). It returns 'idle' | 'loading' | 'ready' | 'error'. It appends the <script> to the body, tracks its load/error events, and — crucially — dedupes: if the same src is already on the page, it reuses that tag instead of injecting a duplicate.
function useScript(src) {
// returns 'idle' | 'loading' | 'ready' | 'error'
}
function Checkout() {
const status = useScript('https://js.stripe.com/v3');
if (status === 'ready') return <StripeForm />;
if (status === 'error') return <p>Failed to load payments.</p>;
return <Spinner />;
}
// Two components both call useScript(sameSrc) — only ONE <script> is injected.
const a = useScript('/maps.js'); // injects
const b = useScript('/maps.js'); // reuses, reports the same status
<script>, set src and async, append to document.body.ready/error; start at loading (or idle when src is falsy).script[src="…"]; if found, don't inject again — read its current status from a data-status attribute you keep updated.src changes (leave the tag so other consumers keep working).You'll inject a <script> for src (or reuse an existing one), mirror its load/error events into a data-status attribute for other consumers, and drive this hook's own status from those same events.
Loading a script has a lifecycle — loading until the browser fetches and runs it, then ready or error — and the DOM only fires load/error once, on the tag that did the fetching. Two things make this fiddly. First, deduping: if three components want the same SDK, you must inject the tag once, not three times, and the components that didn't inject it still need to know the current status. Second, cleanup: each hook adds its own listeners, and those must be removed when it unmounts so you don't leak or update a stale component. The shared source of truth is a data-status attribute on the tag itself.
There's one <script> per URL, and it carries its own status badge (data-status). When a hook mounts for a src: if no tag exists, create it, mark it loading, and wire listeners that update the badge on load/error. If a tag already exists, don't create another — just read its badge to know where things stand. Independently, every hook attaches a private listener so its own React state flips to ready/error when the events fire, and removes that listener on cleanup.
The naive version injects unconditionally and never dedupes:
function useScriptNaive(src) {
const [status, setStatus] = useState('loading');
useEffect(() => {
const script = document.createElement('script'); // always a new tag
script.src = src;
script.onload = () => setStatus('ready');
script.onerror = () => setStatus('error');
document.body.appendChild(script);
}, [src]);
return status;
}
Two failures. Every component that calls it appends another <script> for the same URL, so a shared SDK loads three times (and may re-run side effects). And a second component that mounts after the script already loaded will inject a fresh tag and sit at loading forever if the browser serves the cached script without re-firing load reliably — it has no way to learn the script is already ready. Deduping plus a status badge fixes both.
const { useState, useEffect } = require('react');
function useScript(src) {
const [status, setStatus] = useState(src ? 'loading' : 'idle');
useEffect(() => {
if (!src) {
setStatus('idle');
return;
}
// Dedupe: is this src already on the page?
let script = document.querySelector(`script[src="${src}"]`);
if (!script) {
script = document.createElement('script');
script.src = src;
script.async = true;
script.setAttribute('data-status', 'loading');
document.body.appendChild(script);
// Keep the shared badge current for future hooks.
const setDataStatus = (event) => {
script.setAttribute('data-status', event.type === 'load' ? 'ready' : 'error');
};
script.addEventListener('load', setDataStatus);
script.addEventListener('error', setDataStatus);
} else {
// Already injected — adopt its current status.
setStatus(script.getAttribute('data-status') || 'loading');
}
// This hook's OWN listener, updating its React state.
const setStateFromEvent = (event) => {
setStatus(event.type === 'load' ? 'ready' : 'error');
};
script.addEventListener('load', setStateFromEvent);
script.addEventListener('error', setStateFromEvent);
return () => {
script.removeEventListener('load', setStateFromEvent);
script.removeEventListener('error', setStateFromEvent);
};
}, [src]);
return status;
}
module.exports = { useScript };
The querySelector is the dedupe check. On a first mount for a src, we create the tag, mark it data-status="loading", and attach a setDataStatus listener that keeps the shared badge accurate. On any mount where the tag already exists, we skip injection and setStatus from the badge, so late consumers immediately learn ready/error. Separately — on every mount — we attach setStateFromEvent to drive this hook's own state, and the cleanup removes only that listener (the tag and its shared badge stay for others). Two listeners, two jobs: one keeps the DOM badge truthful, one keeps this component in sync.
Component A loads /maps.js; it loads; then component B mounts wanting the same script:
script[src="/maps.js"] exists. Create it, data-status="loading", append, attach setDataStatus + A's setStateFromEvent. A's status is loading.setDataStatus sets data-status="ready"; A's setStateFromEvent sets A's status to ready.querySelector finds the existing tag. No new injection. setStatus(getAttribute('data-status')) = ready, so B renders as ready right away. B also attaches its own setStateFromEvent (harmless; the event already fired).setStateFromEvent. The tag, its badge, and B's listener are untouched.One tag, one network fetch, both components correctly at ready.
src and reuse.data-status attribute.src in the selector — a URL with quotes/special chars can break querySelector; in production prefer a sanitized attribute or CSS.escape.onload globals — many SDKs expose a global (window.Stripe); pairing ready with a check for that global guards against "loaded but not initialized".script.defer or adding a <link rel="preload"> tunes when the fetch happens relative to rendering.nonce and an integrity hash, both settable on the element before append.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Third-party widgets — Stripe, a maps SDK, an analytics snippet — ship as a <script src> you're supposed to drop in the page <head>. In a SPA you often want to load them lazily, only on the route that needs them, and know when they're ready so you can safely touch their globals. useScript does that: give it a URL, it injects the tag once and returns a status you can render against.
Implement useScript(src). It returns 'idle' | 'loading' | 'ready' | 'error'. It appends the <script> to the body, tracks its load/error events, and — crucially — dedupes: if the same src is already on the page, it reuses that tag instead of injecting a duplicate.
function useScript(src) {
// returns 'idle' | 'loading' | 'ready' | 'error'
}
function Checkout() {
const status = useScript('https://js.stripe.com/v3');
if (status === 'ready') return <StripeForm />;
if (status === 'error') return <p>Failed to load payments.</p>;
return <Spinner />;
}
// Two components both call useScript(sameSrc) — only ONE <script> is injected.
const a = useScript('/maps.js'); // injects
const b = useScript('/maps.js'); // reuses, reports the same status
<script>, set src and async, append to document.body.ready/error; start at loading (or idle when src is falsy).script[src="…"]; if found, don't inject again — read its current status from a data-status attribute you keep updated.src changes (leave the tag so other consumers keep working).You'll inject a <script> for src (or reuse an existing one), mirror its load/error events into a data-status attribute for other consumers, and drive this hook's own status from those same events.
Loading a script has a lifecycle — loading until the browser fetches and runs it, then ready or error — and the DOM only fires load/error once, on the tag that did the fetching. Two things make this fiddly. First, deduping: if three components want the same SDK, you must inject the tag once, not three times, and the components that didn't inject it still need to know the current status. Second, cleanup: each hook adds its own listeners, and those must be removed when it unmounts so you don't leak or update a stale component. The shared source of truth is a data-status attribute on the tag itself.
There's one <script> per URL, and it carries its own status badge (data-status). When a hook mounts for a src: if no tag exists, create it, mark it loading, and wire listeners that update the badge on load/error. If a tag already exists, don't create another — just read its badge to know where things stand. Independently, every hook attaches a private listener so its own React state flips to ready/error when the events fire, and removes that listener on cleanup.
The naive version injects unconditionally and never dedupes:
function useScriptNaive(src) {
const [status, setStatus] = useState('loading');
useEffect(() => {
const script = document.createElement('script'); // always a new tag
script.src = src;
script.onload = () => setStatus('ready');
script.onerror = () => setStatus('error');
document.body.appendChild(script);
}, [src]);
return status;
}
Two failures. Every component that calls it appends another <script> for the same URL, so a shared SDK loads three times (and may re-run side effects). And a second component that mounts after the script already loaded will inject a fresh tag and sit at loading forever if the browser serves the cached script without re-firing load reliably — it has no way to learn the script is already ready. Deduping plus a status badge fixes both.
const { useState, useEffect } = require('react');
function useScript(src) {
const [status, setStatus] = useState(src ? 'loading' : 'idle');
useEffect(() => {
if (!src) {
setStatus('idle');
return;
}
// Dedupe: is this src already on the page?
let script = document.querySelector(`script[src="${src}"]`);
if (!script) {
script = document.createElement('script');
script.src = src;
script.async = true;
script.setAttribute('data-status', 'loading');
document.body.appendChild(script);
// Keep the shared badge current for future hooks.
const setDataStatus = (event) => {
script.setAttribute('data-status', event.type === 'load' ? 'ready' : 'error');
};
script.addEventListener('load', setDataStatus);
script.addEventListener('error', setDataStatus);
} else {
// Already injected — adopt its current status.
setStatus(script.getAttribute('data-status') || 'loading');
}
// This hook's OWN listener, updating its React state.
const setStateFromEvent = (event) => {
setStatus(event.type === 'load' ? 'ready' : 'error');
};
script.addEventListener('load', setStateFromEvent);
script.addEventListener('error', setStateFromEvent);
return () => {
script.removeEventListener('load', setStateFromEvent);
script.removeEventListener('error', setStateFromEvent);
};
}, [src]);
return status;
}
module.exports = { useScript };
The querySelector is the dedupe check. On a first mount for a src, we create the tag, mark it data-status="loading", and attach a setDataStatus listener that keeps the shared badge accurate. On any mount where the tag already exists, we skip injection and setStatus from the badge, so late consumers immediately learn ready/error. Separately — on every mount — we attach setStateFromEvent to drive this hook's own state, and the cleanup removes only that listener (the tag and its shared badge stay for others). Two listeners, two jobs: one keeps the DOM badge truthful, one keeps this component in sync.
Component A loads /maps.js; it loads; then component B mounts wanting the same script:
script[src="/maps.js"] exists. Create it, data-status="loading", append, attach setDataStatus + A's setStateFromEvent. A's status is loading.setDataStatus sets data-status="ready"; A's setStateFromEvent sets A's status to ready.querySelector finds the existing tag. No new injection. setStatus(getAttribute('data-status')) = ready, so B renders as ready right away. B also attaches its own setStateFromEvent (harmless; the event already fired).setStateFromEvent. The tag, its badge, and B's listener are untouched.One tag, one network fetch, both components correctly at ready.
src and reuse.data-status attribute.src in the selector — a URL with quotes/special chars can break querySelector; in production prefer a sanitized attribute or CSS.escape.onload globals — many SDKs expose a global (window.Stripe); pairing ready with a check for that global guards against "loaded but not initialized".script.defer or adding a <link rel="preload"> tunes when the fetch happens relative to rendering.nonce and an integrity hash, both settable on the element before append.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.