Some third-party code still arrives as a classic <script> tag: a payment widget, a maps SDK, an analytics snippet. Loading it from index.html makes every visitor pay for it. A Hook loads it from the component that needs it and reports when it is usable.
import { useEffect, useState } from 'react';
export function useScript(src) {
const [status, setStatus] = useState('loading');
useEffect(() => {
const found = document.querySelector(`script[src="${src}"]`);
if (found) setStatus(found.dataset.status || 'loading');
const el = found || document.body.appendChild(
Object.assign(document.createElement('script'), { src, async: true }));
const done = e => {
el.dataset.status = e.type === 'load' ? 'ready' : 'error';
setStatus(el.dataset.status);
};
const types = ['load', 'error'];
types.forEach(t => el.addEventListener(t, done));
return () => types.forEach(t => el.removeEventListener(t, done));
}, [src]);
return status;
}Callers branch on the status: const status = useScript(SDK_URL);, render a spinner while it is 'loading', and touch window.PaymentSDK only once it is 'ready'.
Note what the cleanup does not do. Removing the <script> element would undo nothing the script already did — globals, listeners and timers survive — while the next mount would download and execute it again. So the tag stays, the listeners go, and data-status lets a later mount skip straight to the final state.
For code you control, await import('./chart.js') is better in every way: a module, typed, splittable by the bundler. And in React 19 7,897 you can render <script async src={url} /> inside the component that needs it; React hoists it and loads it once even when several components render the same tag.