-
-
Save gragland/929e42759c0051ff596bc961fb13cd93 to your computer and use it in GitHub Desktop.
import { useState, useEffect } from 'react'; | |
// Usage | |
function App() { | |
const status = useScript( | |
'https://pm28k14qlj.codesandbox.io/test-external-script.js' | |
); | |
return ( | |
<div> | |
<div> | |
Script status: <b>{status}</b> | |
</div> | |
{status === "ready" && ( | |
<div> | |
Script function call response: <b>{TEST_SCRIPT.start()}</b> | |
</div> | |
)} | |
</div> | |
); | |
} | |
// Hook | |
function useScript(src) { | |
// Keep track of script status ("idle", "loading", "ready", "error") | |
const [status, setStatus] = useState(src ? "loading" : "idle"); | |
useEffect( | |
() => { | |
// Allow falsy src value if waiting on other data needed for | |
// constructing the script URL passed to this hook. | |
if (!src) { | |
setStatus("idle"); | |
return; | |
} | |
// Fetch existing script element by src | |
// It may have been added by another intance of this hook | |
let script = document.querySelector(`script[src="${src}"]`); | |
if (!script) { | |
// Create script | |
script = document.createElement("script"); | |
script.src = src; | |
script.async = true; | |
script.setAttribute("data-status", "loading"); | |
// Add script to document body | |
document.body.appendChild(script); | |
// Store status in attribute on script | |
// This can be read by other instances of this hook | |
const setAttributeFromEvent = (event) => { | |
script.setAttribute( | |
"data-status", | |
event.type === "load" ? "ready" : "error" | |
); | |
}; | |
script.addEventListener("load", setAttributeFromEvent); | |
script.addEventListener("error", setAttributeFromEvent); | |
} else { | |
// Grab existing script status from attribute and set to state. | |
setStatus(script.getAttribute("data-status")); | |
} | |
// Script event handler to update status in state | |
// Note: Even if the script already exists we still need to add | |
// event handlers to update the state for *this* hook instance. | |
const setStateFromEvent = (event) => { | |
setStatus(event.type === "load" ? "ready" : "error"); | |
}; | |
// Add event listeners | |
script.addEventListener("load", setStateFromEvent); | |
script.addEventListener("error", setStateFromEvent); | |
// Remove event listeners on cleanup | |
return () => { | |
if (script) { | |
script.removeEventListener("load", setStateFromEvent); | |
script.removeEventListener("error", setStateFromEvent); | |
} | |
}; | |
}, | |
[src] // Only re-run effect if script src changes | |
); | |
return status; | |
} |
@reverie LICENSE file added to repo, the package.json already specifies MIT license too.
As mentioned earlier by @JureSotosek there is a problem with this script when multiple components require the same script at the same time. The first will cache the script and the following will interpret it as loaded since it is on the cached array. The issue is that you cannot rely on the load state to start using your script. I updated the script to handle this by keeping the element cached and adding a event listener for each new requester.
import { useEffect, useState } from 'react';
const scripts = [];
export default function useScript(src, async = true, defer = true) {
const [ pending, setPending ] = useState(false);
const [ loaded, setLoaded ] = useState(false);
const [ error, setError ] = useState(null);
function onScriptLoad() {
setPending(false);
setLoaded(true);
setError(null);
}
useEffect(() => {
setPending(true);
const scriptIndex = scripts.findIndex(script => script.src === src);
if (scriptIndex !== -1) {
const script = scripts[scriptIndex];
const onScriptError = () => {
setPending(false);
setLoaded(true);
setError(true);
};
script.addEventListener('load', onScriptLoad);
script.addEventListener('error', onScriptError);
return () => {
script.removeEventListener('load', onScriptLoad);
script.removeEventListener('error', onScriptError);
};
} else {
const script = document.createElement('script');
script.src = src;
script.async = async;
script.defer = defer;
scripts.push(script);
const onScriptError = () => {
const index = scripts.findIndex(s => s.src === src);
if (index !== -1) {
scripts.splice(index, 1);
}
script.remove();
setPending(false);
setLoaded(true);
setError(true);
};
script.addEventListener('load', onScriptLoad);
script.addEventListener('error', onScriptError);
document.body.appendChild(script);
return () => {
script.removeEventListener('load', onScriptLoad);
script.removeEventListener('error', onScriptError);
};
}
},[src]);
return [loaded, error, pending];
}
Hope it will help
Thanks everyone for pointing out the issues with this hook and sharing improvements. I've just updated the code. I think this resolves all the issues, but please let me know if I missed anything.
@gragland, thanks! quick question how do we import from the same js file?
I've tried this:
const status = useScript('js/roslib.min.js');
import ROSLIB from 'roslib'
but get the
Module not found: Can't resolve 'roslib...
import has to go in the header and a hook can't go there?
@jaguardo Hooks need to go in the component body. You can read through the docs to understand how they work: https://reactjs.org/docs/hooks-intro.html
Copy... just trying to figure out how to "import" from the body? if that makes sense...
@jaguardo You'd need to add the roslib package to your package.json, install via npm install
, and keep that import at the top of the file. If you're importing the package that way then you don't need useScript at all.
Thanks... that is what I was trying to avoid, I thought the dynamic loading with useScript would help me avoid that. Appreciate it!
I've made a TypeScript version:
import * as React from "react";
type Status = "idle" | "loading" | "ready" | "error";
// Adapted from https://usehooks.com/useScript/
export function useScript(src: string) {
// Keep track of script status ("idle", "loading", "ready", "error")
const [status, setStatus] = React.useState<Status>(src ? "loading" : "idle");
React.useEffect(
() => {
// Allow falsy src value if waiting on other data needed for
// constructing the script URL passed to this hook.
if (!src) {
setStatus("idle");
return undefined;
}
// Fetch existing script element by src
// It may have been added by another intance of this hook
let script: HTMLScriptElement | null = document.querySelector(
`script[src="${src}"]`,
);
if (!script) {
// Create script
script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.status = "loading";
// Add script to document body
document.body.appendChild(script);
// Store status in attribute on script
// This can be read by other instances of this hook
const setAttributeFromEvent = (event: Event) => {
if (script) {
script.dataset.status = event.type === "load" ? "ready" : "error";
}
};
script.addEventListener("load", setAttributeFromEvent);
script.addEventListener("error", setAttributeFromEvent);
} else {
// Grab existing script status from attribute and set to state.
setStatus(script.dataset.status as Status);
}
// Script event handler to update status in state
// Note: Even if the script already exists we still need to add
// event handlers to update the state for *this* hook instance.
const setStateFromEvent = (event: Event) => {
setStatus(event.type === "load" ? "ready" : "error");
};
// Add event listeners
script.addEventListener("load", setStateFromEvent);
script.addEventListener("error", setStateFromEvent);
// Remove event listeners on cleanup
return () => {
if (script) {
script.removeEventListener("load", setStateFromEvent);
script.removeEventListener("error", setStateFromEvent);
}
};
},
[src], // Only re-run effect if script src changes
);
return status;
}
I've also made a TypeScript version from this, with the ability to reuse the script tag and also unload it to clean up the DOM:
https://github.com/PhilippMolitor/react-unity-renderer/blob/dev/src/hooks/useScript.ts
It also includes a jest test suite.
If I try using it and then using an useEffect in the same file where I call it I get the following error: React hooks error: Rendered more hooks than during the previous render
Codesanbox demo page is not working
How did you manage to deal with cors errors? Trying to load in google maps API with the useScript hook, but I'm getting a cors error.
On the react docs, when using a standard script tag, it says to add the crossorigin field like so<script crossorigin src="..."></script>
. Can the same be done in the above hook?
Yes you can add whatever props you want on the script tag. On my version you can just add script.crossorigin = "use-credentials"
it will add the property on the tag. But are you sure that's your problem ? I'm familiar with Google Map API and never had to define CORS strategy on the client side. You may have forgotten to allow your domain name in the Google Api's administration console. CORS is a security handle by the server and Google only allow domains that have been specified for your application.
@gragland :
If I wanted to do something like this:
const [ status1, status2, status3 ] = useScriptMulti(
"script1.js","script2.js","script3.js"
);
Or
const [ status1, status2, status3 ] = useScriptMulti([
"script1.js","script2.js","script3.js"
]);
How could I do such a hook, with below using useScript?
used this code https://usehooks.com/useScript/ in my application, but am getting below error:
'TEST_SCRIPT' is not defined no-undef
Am getting above error here:
{status === "ready" && (
<div style={{marginTop: 20, fontSize: 20}}>
Script function call response: {TEST_SCRIPT.start()}
)}
@JakeGinnivan This looks great, thanks! Any chance you could add a free software license so I can use this in my projects?