souporserious

Build Your Own Code Playground

10 min read

Whether you use Codesandbox, Codepen, or another playground for testing code, you may have wondered how they work under the hood. In this post, we'll build a simplified code playground to test React code.

It wasn’t until Codepen came on the scene that I really started to utilize the power of online code playgrounds. I’d previously used JSFiddle, but the excellent collection of projects in Codepen caught my attention, and it started to change the way I code. Fast forward to today, I rely primarily on Codesandbox for the ease of mimicking the production code I’m used to and being able to prototype ideas quickly.

What We'll Build

Curiously, I’ve always wondered how these products worked and recently needed to build a React preview environment. Today, we’ll create a simple version of these preview environments in NextJS to understand better how they work. Follow along at the GitHub repo to see a working example. Let’s get started!

Getting Started

We need to start with an environment for creating examples. To do this, we’ll start with a simple NextJS project, but note the same will mostly apply to other frameworks like Gatsby and even beyond React.

Install the relevant dependencies in a new directory:

1yarn init
2yarn add next react react-dom @types/react @types/react-dom typescript

We’ll create two pages, one for the preview and one for rendering the editor and the preview. The index page will render the preview in an iframe allowing us to preview things like window events based on the size of the preview rather than the page we render it in.

pages/index.tsx
1import * as React from 'react'
2
3const initialCodeString = `
4import React from 'react'
5
6export default function App() {
7 return (
8 <div>
9 <h1>Hello Playground</h1>
10 <h2>Start editing to see some magic happen!</h2>
11 </div>
12 )
13}
14`.trim()
15
16export default function Index() {
17 const [code, setCode] = React.useState(initialCodeString)
18 return (
19 <div
20 style={{
21 display: 'grid',
22 gridTemplateColumns: '1fr 1fr',
23 minHeight: '100vh',
24 }}
25 >
26 <textarea
27 spellCheck="false"
28 value={code}
29 onChange={(event) => setCode(event.target.value)}
30 />
31 <Preview />
32 </div>
33 )
34}
35
36function Preview() {
37 return <iframe src="/preview" />
38}

For now, we’ll leave the preview page empty and fill it in as we go along.

pages/preview.tsx
1export default function Preview() {
2 return null
3}

Getting Code

To start, we need to get the code we want to render. We’ll do this by allowing our preview page to accept a compressed code string through a query parameter called code that we can access using next/router. We do this using the base64-url library, which decodes the query parameter to access the code string every time the query changes. Shoutout to Playroom for the inspiration for this approach!

1yarn add base64-url
pages/preview.tsx
1import * as React from 'react'
2import { decode } from 'base64-url'
3import { useRouter } from 'next/router'
4
5export default function Preview() {
6 const [code, setCode] = React.useState('')
7 const [preview, setPreview] = React.useState(null)
8 const router = useRouter()
9
10 /** Decode "code" query parameter */
11 useEffect(() => {
12 if (router.query.code) {
13 setCode(decode(router.query.code as string))
14 }
15 }, [router.query.code])
16
17 return preview
18}

Now back at our index page, we can hook up our Preview component by encoding the incoming code string and passing it to the preview page as a query parameter:

pages/index.tsx
1import { encode } from 'base64-url'
2
3export default function Index() {
4 ...
5 <Preview code={code} />
6 ...
7}
8
9function Preview({ code = '' }) {
10 return <iframe src={`/preview?code=${encode(code)}`} />
11}

If you inspect the iframe at this point, you’ll see that it’s passing in a new compressed string on every change to the textarea.

Executing Code

Now that we have a code string, we need to execute it and get the result. We must compile our code at runtime since we want to write imports and exports with other modern JavaScript. This is similar to what we would need in a build step for a library or application. We’ll use SWC to accomplish this, a performant compiler we can use to transpile our code for the browser. We’ll use the official playground as inspiration for most of the following code.

Load SWC

First, we need to load the SWC web package, which we’ll dynamically in a new transformCode utility. This way, the library will load after the initial page load:

1yarn add @swc/wasm-web
utils/execute-code.ts
1let swc = null
2
3export async function transformCode(code: string) {
4 if (swc === null) {
5 const module = await import('@swc/wasm-web')
6 await module.default()
7 swc = module
8 }
9}

Transform Code

Now that we have an swc variable, we can use transformSync with a simple configuration that compiles TypeScript and JSX code:

utils/execute-code.ts
1export async function transformCode(codeString: string) {
2 if (swc === null) {
3 const module = await import('@swc/wasm-web')
4 await module.default()
5 swc = module
6 }
7 return swc.transformSync(codeString, {
8 filename: 'index.tsx',
9 jsc: {
10 parser: {
11 syntax: 'typescript',
12 tsx: true,
13 },
14 },
15 module: {
16 type: 'commonjs',
17 },
18 }).code
19}

Execute Code

Now we’re ready to execute code and get the result. We’ll add a new executeCode utility for this in the same file:

utils/execute-code.ts
1export async function executeCode(
2 codeString: string,
3 dependencies: Record<string, unknown>
4) {
5 const transformedCode = await transformCode(codeString)
6 const exports: Record<string, unknown> = {}
7 const require = (path) => {
8 if (dependencies[path]) {
9 return dependencies[path]
10 }
11 throw Error(`Module not found: ${path}.`)
12 }
13 const result = new Function('exports', 'require', transformedCode)
14
15 result(exports, require)
16
17 return exports.default
18}

A bit is going on here, so let’s break it down:

  1. Transform the incoming code string into a string of JavaScript that we can execute.
  2. Create a dynamic function using the new Function constructor to execute the transformed code. Notice, we use the CommonJS variables here, like require, which handles loading any dependencies of this code string.
  3. Lastly, we execute the dynamic function and return the exported result.

Congrats, you made it! 🥳 We now have a very simple bundler. Now, we’re ready to use our new utility to execute the code and get the result of the preview:

pages/preview.tsx
1import * as React from 'react'
2import { decode } from 'base64-url'
3import { useRouter } from 'next/router'
4import { executeCode } from '../utils/execute-code'
5
6export default function Preview() {
7 const [code, setCode] = React.useState('')
8 const [loading, setLoading] = React.useState(false)
9 const [error, setError] = React.useState(null)
10 const [preview, setPreview] = React.useState(null)
11 const router = useRouter()
12
13 /** Decode "code" query parameter */
14 React.useEffect(() => {
15 if (router.query.code) {
16 setCode(decode(router.query.code as string))
17 }
18 }, [router.query.code])
19
20 /** Execute preview to render */
21 React.useEffect(() => {
22 if (code === null) return
23
24 setError(null)
25 setLoading(true)
26
27 executeCode(code, { react: React })
28 .then((Preview: React.ComponentType) => {
29 setPreview(<Preview />)
30 })
31 .catch((error) => {
32 setError(error.toString())
33 })
34 .finally(() => {
35 setLoading(false)
36 })
37 }, [code])
38
39 return (
40 <>
41 {loading ? 'Loading preview...' : preview}
42 {error}
43 </>
44 )
45}

If everything went well, we should now see a preview of our code. And if there is an error, we’ll show the error message.

Loading Dependencies

We aren’t loading any dependencies here, but we can add any libraries we’d like. For example, we could add framer-motion and then have access to use that library in our code string:

1import * as React from 'react-dom'
2import * as ReactDOM from 'react-dom'
3import * as FramerMotion from 'framer-motion'
4import { executeCode } from '../utils/execute-code'
5
6const codeString = `
7import { motion } from 'framer-motion'
8
9export default function App() {
10 return (
11 <motion.div animate={{ rotate: 360 }} />
12 )
13}
14`
15
16executeCode(codeString, {
17 react: React,
18 'framer-motion': FramerMotion,
19}).then((App) => {
20 ReactDOM.render(<App />, document.getElementById('root'))
21})

Also, notice that previously when we created our executeCode utility, we got the result from exports.default after calling the function. Take note App is just a standard React component now and will be compiled further to React.createElement. We could do that here if we wanted to support multiple exports, although sticking to the default export works well for simple examples.

Better Performance

You should now see the preview of your code update when changing code in the textarea. However, performance is not that great. We’re reloading the iframe on every keystroke, which causes things to feel sluggish. While this is nice for our initial rendering of the preview, it’s not great for performance when continually updating.

Message Passing

We can utilize a remarkable API of browsers called postMessage. The postMessage API allows us to send messages from one window to another. We can use this to send the code to the iframe and execute it much faster than updating the query.

We can easily add this feature by modifying the preview page to listen for messages from the iframe and then update the preview:

pages/preview.tsx
1function Preview() {
2 ...
3
4 React.useEffect(() => {
5 function handleMessage(event: MessageEvent) {
6 if (
7 window.location.origin === event.origin &&
8 event.data.type === 'preview'
9 ) {
10 setCode(decode(event.data.code))
11 }
12 }
13
14 window.addEventListener('message', handleMessage)
15
16 return () => {
17 window.removeEventListener('message', handleMessage)
18 }
19 }, [])
20
21 ...
22}

For security purposes, you should further sanitize the incoming data and add an identifier to make sure you’re dealing with code you trust. Also, as Ori mentions here, in a real-world situation you most likely want to run this playground on a subdomain to prevent sharing localStorage.

Now, similarly as before, we’ll add a useEffect hook in our index page to send the code to the iframe:

pages/index.tsx
1function Preview({ code }) {
2 const frameRef = React.useRef<HTMLIFrameElement>(null)
3 const frameSource = React.useRef(null)
4
5 /**
6 * Only set the source of the iframe on the initial mount since we use message
7 * passing below for subsequent updates.
8 */
9 if (frameSource.current === null) {
10 frameSource.current = `/preview?code=${encode(code)}`
11 }
12
13 React.useEffect(() => {
14 frameRef.current.contentWindow.postMessage({
15 code: encode(code),
16 type: 'preview',
17 })
18 }, [code])
19
20 return <iframe ref={frameRef} src={frameSource.current} />
21}

Now we can send the code directly to the iframe and have it execute much faster than updating the query.

As a bonus, since we started with query params, we can easily add shareable links to our code. This is a great way to share code with others and load the iframe with the exact code you want. I’ll leave this one as an exercise for you to implement on your own 😁

Conclusion

Compared to full-featured bundlers, this was a more simple look at creating a preview environment that renders any arbitrary React code. Depending on your needs, something like Sandpack or Javascript Playgrounds is probably easier to get going. Still, now you hopefully understand how to customize these libraries or create your own.

Resources

Huge thank you to the resources listed throughout this post and below, which helped me understand the process behind bundling and executing code in the browser.

Codetree

Build your own interactive JavaScript playground

Updated:
  • javascript
  • react
Previous post
Generate TypeScript Docs Using TS Morph
Next post
Managing React Context in Server Components
© 2022 Travis Arnold