Creating a CodePen-like editor with React

Articles|Håkon Underbakke | over 1 year ago

So I’ve been working on this educational site, an internal project at my company. One of the things I really wanted as a big feature was a on-site code-editor that can run React-code. In my mind this seemed like it was a pretty big task, but it turns out it’s actually relatively straight forward.

First of all, how do we run this code safely, isolated from the rest of the page? Looking at other similar sites, they all seem to have concluded with using an iFrame. So that begs the question, how do we inject code into said iFrame?

const PreviewCode = () => {
   const frameRef = useRef();
   
   const injectSomeCode = () => {
      if (frameRef?.current) {
         const frameWindow = frameRef.current.contentWindow;
         const frameDoc = frameWindow.document;
         // Now we could do something like
         frameDoc.body.innerHTML = "<h1>Hi</h1>";
      }
   }

   return (
      <div>
         {/* ... */}
         <iframe ref={frameRef} />
      </div>
   )
}

As you can see, we can actually access the iFrame’s Window and Document objects, and hence manipulate it’s DOM. Now that we know this, we could technically separate HTML, JS and CSS like such:

   const injectSomeCode = (js, html, css) => {
      if (frameRef?.current) {
         const frameWindow = frameRef.current.contentWindow;
         const frameDoc = frameWindow.document;
         if (html) {
           const htmlRoot = frameDoc.createElement("div");
           htmlRoot.innerHTML = html;
           frameDoc.body.appendChild(htmlRoot);
         }
         if (css) {
           const cssRoot = frameDoc.createElement("style");
           cssRoot.innerHTML = css;
           frameDoc.head.appendChild(cssRoot);
         }
         if (js) {
           const jsRoot = frameDoc.createElement("script");

           try {
              jsRoot.innerHTML = js;
              frameDoc.body.appendChild(jsRoot);
           } catch(err) {
              console.log("Compile error", err);
           }
         }
      }
   }

So now we’re able to inject HTML, CSS and Javascript into the iFrame, logically separated – yay! But, we want to run React inside the iFrame, with JSX, which means that we need a couple of extra steps.

You can use frameDoc.createElement to create script tags that point to React’s CDN – but to spare you from banging your head in your keyboard like I did for a while – I’ll tell you the correct way to do this.

Let’s say you have an array of script-urls, scripts, which you want to load into the document synchronously in the same order as they are in the array. One would think that something like this would work, right?

scripts.forEach((scriptURL) => {
   const tag = frameDoc.createElement("script");
   tag.src = scriptURL;
   frameDoc.body.appendChild(tag);
});

Well… no, this would not work. The reason is that we are not actually ensuring that the first script is loaded before the second one – this loop will finish before the first request is finished. This means that if your user’s Javascript is dependant on for example React being defined, then we’re not currently ensuring that. So I ended up doing something like this instead:

const scripts = ["...", "...", "..."];

const onFinishedLoading = () => injectUserScript();

const loadScripts = (
    index = 0,
) => {
    const frameDoc = frameRef.current.contentWindow.document;
    const scriptURL = scripts[index];
    const scriptTag = frameDoc.createElement("script");
    scriptTag.onload = () => {
       if (index === scripts.length - 1){
          onFinishedLoading();
       }else{
          loadScripts(index + 1);
       }
    };
    scriptTag.src = scriptURL;
    frameDoc.body.appendChild(scriptTag);
};
 
loadScripts();

In this version, we’re using the script-tag’s onload event listener to wait for each script to load before loading the next one. This ensures that imports are defined before the user’s script is loaded.

Now for the final step – parsing the user-provided Javascript code through babel so that we’ll be able to use JSX and ES2015 features. This is, to my surprise, really easy to set up:

import { transform } from "@babel/standalone"; 

const parsedJS = transform(js, {
   presets: ["es2015", "react"]
}).code;

That’s mostly it. Now you can inject React and ReactDOM into the iFrame, use some user-provided HTML, JS & CSS – transform it through babel and inject that to the iFrame after the scripts have loaded and then voilà, you have your code-editor hooked up to a live preview!

If you are interested in seeing my actual implementation (at the time of writing), I’ve abstracted it into a hook and published that as a gist on the link below this paragraph. I might publish this as an NPM package at some point so that you wouldn’t have to install the dependencies yourself, but for now you atleast have the code to look at.

useCodePreview.ts