Better Programming

Advice for programmers.

Follow publication

How To Make a React Component Editor with Live Preview

Saumitra Dey
Better Programming
Published in
5 min readOct 27, 2019

--

final demo

Have you ever wondered how those live react editors like codesandbox and codepen work? If you ever used semantic react or react styleguidist you should know you can interact with their examples right in the browser with live preview.

This weekend I finally put the pieces together and created a simple working solution. This article is an overview of the experiment. If you’re not sure what we’re building I recommend you scroll to the bottom of this article and interact with the embedded codesandbox demo first.

OK, let’s dive right into it.

Challenges that we have to overcome:

  • Transpiling JSX/ES6 in the browser.
  • Some sort of module resolution, so we can import modules into the editor.
  • Some way to parse and modify JavaScript code.

Packages used:

  • @babel/standalone to transpile JSX/ES6 in the browser.
  • acorn to parse JS into AST (don’t worry for our use case the AST manipulation is really simple).
  • escodegen to convert the modified AST back to JS code.
  • debounce and object-path for their respective usage.

The Strategy

It’s surprisingly simple. Here are the steps:

  1. Transpile the JSX/ES6 code
  2. Find a JSX expression statement from the transpiled code body. We’ll dive into this later when we cover the AST processing part.
  3. Modify the JSX expression so that it’s wrapped in a render call.
  4. Create a new wrapper function which contains the code generated so far from the above steps and all the dependencies passed as arguments.
  5. Call the function created in step four whenever code changes

Confused? No worries, let’s take an actual sample to demonstrate the point.

Let’s say we start with the following code:

How can we make this code be rendered to an arbitrary DOM element on our webpage?

Our task now is to transpile the above code, resolve the required button component, and render the JSX at line 12.

Here’s the transpiled version:

Here’s what we want to generate dynamically:

Once we are able to generate the above function, we can call it with React module object, a render function and module resolver function as argument (in that order).

Also, note that we’ve wrapped the 10th line of the transpiled code with a invocation of render function.

I hope you got the picture now. Lets look at some code:

import React from "react";
import ReactDOM from "react-dom";
import ObjPath from "object-path";

import * as Acorn from "acorn";

import { generate as generateJs } from "escodegen";
import { transform as babelTransform } from "@babel/standalone";

function isReactNode(node) {
const type = node.type; //"ExpressionStatement"
const obj = ObjPath.get(node, "expression.callee.object.name");
const func = ObjPath.get(node, "expression.callee.property.name");
return (
type === "ExpressionStatement" &&
obj === "React" &&
func === "createElement"
);
}

export function findReactNode(ast) {
const { body } = ast;
return body.find(isReactNode);
}

export function createEditor(domElement, moduleResolver = () => null) {
function render(node) {
ReactDOM.render(node, domElement);
}

function require(moduleName) {
return moduleResolver(moduleName);
}

function getWrapperFunction(code) {
try {
// 1. transform code
const tcode = babelTransform(code, { presets: ["es2015", "react"] })
.code;

// 2. get AST
const ast = Acorn.parse(tcode, {
sourceType: "module"
});

// 3. find React.createElement expression in the body of program
const rnode = findReactNode(ast);

if (rnode) {
const nodeIndex = ast.body.indexOf(rnode);
// 4. convert the React.createElement invocation to source and remove the trailing semicolon
const createElSrc = generateJs(rnode).slice(0, -1);
// 5. transform React.createElement(...) to render(React.createElement(...)),
// where render is a callback passed from outside
const renderCallAst = Acorn.parse(`render(${createElSrc})`)
.body[0];

ast.body[nodeIndex] = renderCallAst;
}

// 6. create a new wrapper function with all dependency as parameters
return new Function("React", "render", "require", generateJs(ast));
} catch (ex) {
// in case of exception render the exception message
render(<pre style={{ color: "red" }}>{ex.message}</pre>);
}
}

return {
// returns transpiled code in a wrapper function which can be invoked later
compile(code) {
return getWrapperFunction(code);
},

// compiles and invokes the wrapper function
run(code) {
this.compile(code)(React, render, require);
},

// just compiles and returns the stringified wrapper function
getCompiledCode(code) {
return getWrapperFunction(code).toString();
}
};
}

We create a new editor instance by calling the createEditor function. This function accepts two arguments:

  1. The target DOM element where the result will be rendered.
  2. A module resolver callback function.

The main workhorse here is the getWrapperFunction. Also, refer to the below image of a generated AST tree from the default example code to help you understand how we’re detecting and modifying the JSX expression in the program body.

To understand how the isReactNode and findReactNode function works, compare the code with the above AST. The important thing to remember here is that when we call Acorn.parse with an arbitrary code string the parser treats the code string as a complete JavaScript program, hence the body array represents statements at theglobal level. We want to find the React.createElement statement here.

Now let’s look at the example below:

import "./styles.scss";
import React from "react";
import ReactDOM from "react-dom";
import { createEditor } from "./editor";
import debounce from "debounce";

// default code
const code = `import x from 'x';

// edit this example

function Greet() {
return <span>Hello World!</span>
}

<Greet />
`;


class SandBox extends React.Component {
state = {
code
};

editor = null;

el = null;

componentDidMount() {
this.editor = createEditor(this.el);
this.editor.run(code);
}

onCodeChange = ({ target: { value } }) => {
this.setState({ code: value });
this.run(value);
};

run = debounce(() => {
const { code } = this.state;
this.editor.run(code);
}, 500);

render() {
const { code } = this.state;
return (
<div className="app">
<div className="split-view">
<div className="code-editor">
<textarea value={code} onChange={this.onCodeChange} />
</div>
<div className="preview" ref={el => (this.el = el)} />
</div>
</div>
);
}
}

const rootElement = document.getElementById("root");
ReactDOM.render(<SandBox />, rootElement);

Where Can You Use This?

This was a really fun experiment, I think techniques can be really useful in the following scenarios:

  • Component documentation.
  • Online IDEs
  • As a simple dynamic JSX render

It’s up to you!

Links

Try this live (sandbox in sandbox):

Wrapping Up

That’s about it.

You might have already noticed that I have not yet implemented the module resolver part. It’s really straightforward and easy to implement so I leave that up to the reader.

Thank you for reading!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Saumitra Dey
Saumitra Dey

Written by Saumitra Dey

Software engineer @ Tekion, half programmer half UI designer.

Write a response