How to build a live code playground with React
You will need Node 6+ installed on your machine.
In this tutorial, we’ll go through how to build a code editor with React, while syncing the changes made in realtime across all connected clients with Pusher Channels. You can find the entire source code for the application in this GitHub repository.
Prerequisites
You need to have experience with building React and Node.js applications to follow through with this tutorial. You also need to have Node.js (version 6 or later) and npm installed on your machine. Installation instructions for Node.js can be found on this page.
Set up the server
Create a new directory for this project on your machine and cd
into it:
mkdir code-playground
cd code-playground
Next, initialize a new Node project by running the command below. The -y
flag allows us to accept all the defaults without being prompted.
npm init -y
Next, install the dependencies we’ll be using to set up the Node server:
npm install express body-parser dotenv cors pusher --save
Once the dependencies have been installed, create a new server.js
file in the root of your project directory and paste in the following code:
// server.js
require('dotenv').config({ path: '.env' });
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const Pusher = require('pusher');
const app = express();
app.use(cors())
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.set('port', process.env.PORT || 5000);
const server = app.listen(app.get('port'), () => {
console.log(`Express running → PORT ${server.address().port}`);
});
Save the file and create a .env
file in the root of your project directory. Change its contents to look like this:
// .env
PORT=5000
Set up Channels integration
Head over to the Pusher website and sign up for a free account. Select Channels apps on the sidebar, and hit Create Channels app to create a new app. Once your app is created, retrieve your credentials from the API Keys tab, then add the following to the .env
file:
// .env
PORT=5000
PUSHER_APP_ID=<your app id>
PUSHER_APP_KEY=<your app key>
PUSHER_APP_SECRET=<your app secret>
PUSHER_APP_CLUSTER=<your app cluster>
Next, initialize the Pusher SDK within server.js
:
require('dotenv').config({ path: '.env' });
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const Pusher = require('pusher');
const app = express();
const pusher = new Pusher({
appId: process.env.PUSHER_APP_ID,
key: process.env.PUSHER_APP_KEY,
secret: process.env.PUSHER_APP_SECRET,
cluster: process.env.PUSHER_APP_CLUSTER,
useTLS: true,
});
app.use(cors())
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.set('port', process.env.PORT || 5000);
const server = app.listen(app.get('port'), () => {
console.log(`Express running → PORT ${server.address().port}`);
});
Set up the React application
Make sure you have the create-react-app package installed globally on your machine. Otherwise, run, npm install -g create-react-app
.
Next, run the following command to bootstrap your React app:
create-react-app client
Once the command above has finished running, cd
into the newly created client
directory and install the other dependencies which we’ll be needing for our app’s frontend:
npm install pusher-js axios pushid react-codemirror2 codemirror --save
Now, you can run npm start
from within the client
directory to start the development server and navigate to http://localhost:3000 in your browser.
Add the styles for the app
Before we tackle the application logic, let’s add all the styles we need to create the code playground. Within the client
directory, locate src/App.css
and change its contents to look like this:
// client/src/App.css
html {
box-sizing: border-box;
}
*, *::before, *::after {
box-sizing: inherit;
margin: 0;
padding: 0;
}
.playground {
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 600px;
background-color: #1E1E2C;
}
.code-editor {
height: 33.33%;
overflow: hidden;
position: relative;
}
.editor-header {
height: 30px;
content: attr(title);
display: flex;
align-items: center;
padding-left: 20px;
font-size: 18px;
color: #fafafa;
}
.react-codemirror2 {
max-height: calc(100% - 30px);
overflow: auto;
}
.result {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 600px;
overflow: hidden;
}
.iframe {
width: 100%;
height: 100%;
}
Render the code playground
Open up client/src/App.js
and change it to look like this:
// client/src/App.js
import React, { Component } from 'react';
import { Controlled as CodeMirror } from 'react-codemirror2';
import Pusher from 'pusher-js';
import pushid from 'pushid';
import axios from 'axios';
import './App.css';
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/material.css';
import 'codemirror/mode/htmlmixed/htmlmixed';
import 'codemirror/mode/css/css';
import 'codemirror/mode/javascript/javascript';
class App extends Component {
constructor() {
super();
this.state = {
id: '',
html: '',
css: '',
js: '',
};
}
componentDidUpdate() {
this.runCode();
}
componentDidMount() {
this.setState({
id: pushid(),
});
}
runCode = () => {
const { html, css, js } = this.state;
const iframe = this.refs.iframe;
const document = iframe.contentDocument;
const documentContents = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
${css}
</style>
</head>
<body>
${html}
<script type="text/javascript">
${js}
</script>
</body>
</html>
`;
document.open();
document.write(documentContents);
document.close();
};
render() {
const { html, js, css } = this.state;
const codeMirrorOptions = {
theme: 'material',
lineNumbers: true,
scrollbarStyle: null,
lineWrapping: true,
};
return (
<div className="App">
<section className="playground">
<div className="code-editor html-code">
<div className="editor-header">HTML</div>
<CodeMirror
value={html}
options={{
mode: 'htmlmixed',
...codeMirrorOptions,
}}
onBeforeChange={(editor, data, html) => {
this.setState({ html });
}}
/>
</div>
<div className="code-editor css-code">
<div className="editor-header">CSS</div>
<CodeMirror
value={css}
options={{
mode: 'css',
...codeMirrorOptions,
}}
onBeforeChange={(editor, data, css) => {
this.setState({ css });
}}
/>
</div>
<div className="code-editor js-code">
<div className="editor-header">JavaScript</div>
<CodeMirror
value={js}
options={{
mode: 'javascript',
...codeMirrorOptions,
}}
onBeforeChange={(editor, data, js) => {
this.setState({ js });
}}
/>
</div>
</section>
<section className="result">
<iframe title="result" className="iframe" ref="iframe" />
</section>
</div>
);
}
}
export default App;
We’re making use of react-codemirror2, a thin wrapper around the codemirror
package for our code editor. We have three instances here, one for HTML, another for CSS and the last one for JavaScript.
Once the code in any one of the editors is updated, the runCode()
function is triggered and the code is executed and rendered in an iframe.
Sync updates in realtime with Pusher
Let’s make it possible for multiple collaborators to edit and preview the code at the same time. We can do this pretty easily with Channels.
First, return to the server.js
file you created earlier and add the following code into it :
// server.js
//beginning of the file
app.use(bodyParser.json());
app.post('/update-editor', (req, res) => {
pusher.trigger('editor', 'code-update', {
...req.body,
});
res.status(200).send('OK');
});
// rest of the file
We’ll make a POST
request to this route from the application frontend and pass in the contents of each of the code editors in the request body. We then trigger a code-update
event on the editor
channel each time a request is make to this route.
For this to work, we need to subscribe to the editor
channel and listen for the code-update
event on the frontend.
Let’s do just that in App.js
:
// client/src/App.js
// beginning of the file
class App extends Component {
constructor() {
super();
this.state = {
id: "",
html: "",
css: "",
js: ""
};
this.pusher = new Pusher("<your app key>", {
cluster: "<your app cluster>",
forceTLS: true
});
this.channel = this.pusher.subscribe("editor");
}
componentDidUpdate() {
this.runCode();
}
componentDidMount() {
this.setState({
id: pushid()
});
this.channel.bind("code-update", data => {
const { id } = this.state;
if (data.id === id) return;
this.setState({
html: data.html,
css: data.css,
js: data.js,
});
});
}
syncUpdates = () => {
const data = { ...this.state };
axios
.post("http://localhost:5000/update-editor", data)
.catch(console.error);
};
// rest of the file
}
export default App;
Then update the render function as follows:
// client/src/App.js
render() {
const { html, js, css } = this.state;
const codeMirrorOptions = {
theme: "material",
lineNumbers: true,
scrollbarStyle: null,
lineWrapping: true
};
return (
<div className="App">
<section className="playground">
<div className="code-editor html-code">
<div className="editor-header">HTML</div>
<CodeMirror
value={html}
options={{
mode: "htmlmixed",
...codeMirrorOptions
}}
onBeforeChange={(editor, data, html) => {
this.setState({ html }, () => this.syncUpdates()); // update this line
}}
/>
</div>
<div className="code-editor css-code">
<div className="editor-header">CSS</div>
<CodeMirror
value={css}
options={{
mode: "css",
...codeMirrorOptions
}}
onBeforeChange={(editor, data, css) => {
this.setState({ css }, () => this.syncUpdates()); // update this line
}}
/>
</div>
<div className="code-editor js-code">
<div className="editor-header">JavaScript</div>
<CodeMirror
value={js}
options={{
mode: "javascript",
...codeMirrorOptions
}}
onBeforeChange={(editor, data, js) => {
this.setState({ js }, () => this.syncUpdates()); // update this line
}}
/>
</div>
</section>
<section className="result">
<iframe title="result" className="iframe" ref="iframe" />
</section>
</div>
);
}
In the class constructor, we initialized the Pusher client library and subscribed to the editor
channel. In the syncUpdates()
method, we’re making a request to the /update-editor
route that was created earlier. This method is triggered each time a change is made in any of the code editors.
Finally, we’re listening for the code-update
event in componentDidMount()
and updating the application state once the event is triggered. This allows code changes to be synced across all connected clients in realtime.
Before you test the app, make sure to kill the server with Ctrl-C
(if you have it running), and start it again with node server.js
so that the latest changes are applied.
Wrap up
You have now learned how easy it is to create a code playground with realtime collaboration features with Pusher Channels.
Thanks for reading! Remember that you can find the source code of this app in this GitHub repository.
26 February 2019
by Ayooluwa Isaiah