Skip to content

Instantly share code, notes, and snippets.

@gragland
Last active December 22, 2022 10:53
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save gragland/929e42759c0051ff596bc961fb13cd93 to your computer and use it in GitHub Desktop.
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;
}
@tikotzky
Copy link

It seems that if the script is cached then state doesn’t get updated and loading will stay false.

@gragland
Copy link
Author

@tikotzky Woops, good catch! Updated the code with a fix.

@mohammedzamakhan
Copy link

mohammedzamakhan commented Nov 19, 2018

it can just return state, instead of [state.loaded, state.error], considering https://twitter.com/_developit/status/1057636803354648582

@stunaz
Copy link

stunaz commented Jan 19, 2019

concurrency issue

@webOS101
Copy link

webOS101 commented May 4, 2019

Seems like the script status should be stored in the cache so that it doesn't get marked as loaded if the same script is attempted during loading.

@d-arrington
Copy link

d-arrington commented Jun 6, 2019

Just came across this and had a few thoughts.

it can just return state, instead of [state.loaded, state.error], considering https://twitter.com/_developit/status/1057636803354648582

Even if it's faster, that's not a great practice because it causes needless re-renders on the subscribed component any time the state object is changed. It would work better with useReducer instead of useState, but then you're adding a reducer function to justify the imperceptible speed boost.

concurrency issue

There shouldn't be any issues with concurrency or duplicate scripts being loaded, since src is added to cachedScripts as soon as it's received.

Seems like the script status should be stored in the cache so that it doesn't get marked as loaded if the same script is attempted during loading.

Definitely an issue. If Component A is loading a script, and then Component B comes along and tries to load it, Component B will think it's loaded before it actually is. However, you can't just add the loaded state to the cachedScripts array, you have to also add callbacks. Otherwise, in the above example, Component B would never update its state with the script is finished loading.

Another problem with the script: If src is changed (unlikely, but plausible), the state is never reset, so the component will always think the script is already loaded. The simple solution is to add this after line 41:

setState({loaded: false, error: false})

@JureSotosek
Copy link

JureSotosek commented Jul 19, 2019

A few problems I've encountered with this hook:

  1. If I try to use useScript with the same script two different times in a short period of time, one useScript is going to say that the script is loaded, even before it actually is loaded since the other will push the src of the script to cachedScripts before the loading ends. (Look at L42)

    This seems like it can be fixed by just moving the cachedScripts.push(src) to after the script has been loaded, but that introduces another problem, where the same script can be included two different times, which can break some scripts.

  2. If I quickly mount and then unmount a useScript.

    The src will be pushed to cachedScripts, but if then an error occurs, it will never be removed from cachedScripts, since on unmount you remove the error eventListener.


I fixed all of those issues by introducing another loadingScripts variable as shown below 👇🏼

Keep in mind that loadingScripts holds actual script objects and not just srcs.

// Hook
let cachedScripts = [];
let loadingScripts = [];
function useScript(src) {
  // Keeping track of script loaded and error state
  const [state, setState] = useState({
    loaded: false,
    error: false
  });

  useEffect(
    () => {
      // If cachedScripts array already includes src that means another instance ...
      // ... of this hook already loaded this script, so no need to load again.
      if (cachedScripts.includes(src)) {
        setState({
          loaded: true,
          error: false
        });
      } else {
        let script 

        // Create script only if one is not being loaded
        if (loadingScripts[src]) {
          script = loadingScripts[src]
        } else {
          script = document.createElement('script')
          script.src = src
          script.async = true

          // Add script to loadingScripts
          loadingScripts[src] = script

          // Add script to document body
          document.body.appendChild(script)
        }

        // Script event listener callbacks for load and error
        const onScriptLoad = () => {
          // Add script to cachedScripts
          cachedScripts[src] = script

          // Remove from loadingScripts
	  delete loadingScripts[src]

          setState({
            loaded: true,
            error: false
          });
        };

        const onScriptError = () => {
          // Remove from loadingScripts we can try loading again
	  delete loadingScripts[src]
	  script.remove()

          setState({
            loaded: true,
            error: true
          });
        };

        script.addEventListener('load', onScriptLoad);
        script.addEventListener('error', onScriptError);

        // Add script to document body
        document.body.appendChild(script);

        // Remove event listeners on cleanup
        // and remove script from loadingScripts if not yet loaded
        return () => {
           // Remove from loadingScripts
	  delete loadingScripts[src]

          script.removeEventListener('load', onScriptLoad);
          script.removeEventListener('error', onScriptError);
        };
      }
    },
    [src] // Only re-run effect if script src changes
  );

  return [state.loaded, state.error];
}

Hope this helps anybody ✌🏼

@bernard-leech
Copy link

@JureSotosek when do you push the loaded script src to the cachedScripts array?

@JureSotosek
Copy link

JureSotosek commented Aug 15, 2019

@JureSotosek when do you push the loaded script src to the cachedScripts array?

@bernard-leech Ahh, mistake, now its added to cachedScripts at onScriptLoad. Be careful with my code since it's not 100% tested. I did some rewriting when posting it here to make it look closer to what the original useScript looked like.

@JakeGinnivan
Copy link

We have published https://www.npmjs.com/package/use-script which fixes the concurrency issues in this version of the hook. The above implementation can cause scripts which are currently being loaded to be loaded twice.

@reverie
Copy link

reverie commented Feb 12, 2020

Thanks, @JakeGinnivan! Is your project on github anywhere?

I also found this alternative, haven't tested either. https://www.npmjs.com/package/@charlietango/use-script

@JakeGinnivan
Copy link

Quick look at the code in https://www.npmjs.com/package/@charlietango/use-script seems to have the same race conditions :(

@reverie
Copy link

reverie commented Feb 17, 2020

@JakeGinnivan This looks great, thanks! Any chance you could add a free software license so I can use this in my projects?

@JakeGinnivan
Copy link

@reverie LICENSE file added to repo, the package.json already specifies MIT license too.

@lucien-perouze
Copy link

lucien-perouze commented Apr 22, 2020

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

@gragland
Copy link
Author

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.

@jaguardo
Copy link

jaguardo commented Dec 22, 2020

@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?

@gragland
Copy link
Author

@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

@jaguardo
Copy link

Copy... just trying to figure out how to "import" from the body? if that makes sense...

@gragland
Copy link
Author

gragland commented Dec 22, 2020

@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.

@jaguardo
Copy link

Thanks... that is what I was trying to avoid, I thought the dynamic loading with useScript would help me avoid that. Appreciate it!

@wavebeem
Copy link

wavebeem commented Feb 11, 2021

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;
}

@PhilippMolitor
Copy link

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.

@Ladvace
Copy link

Ladvace commented Sep 15, 2021

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

@hieund20
Copy link

Codesanbox demo page is not working

@MeikyuuTrader
Copy link

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?

@lucien-perouze-cezembre

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.

@Angelk90
Copy link

@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?

@harishkotagiri
Copy link

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()}

)}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment