How to build a live code playground with React

Introduction

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:

1mkdir code-playground
2    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:

1// server.js
2    
3    require('dotenv').config({ path: '.env' });
4    
5    const express = require('express');
6    const bodyParser = require('body-parser');
7    const cors = require('cors');
8    const Pusher = require('pusher');
9    
10    const app = express();
11    
12    app.use(cors())
13    app.use(bodyParser.urlencoded({ extended: false }));
14    app.use(bodyParser.json());
15    
16    app.set('port', process.env.PORT || 5000);
17    const server = app.listen(app.get('port'), () => {
18      console.log(`Express running → PORT ${server.address().port}`);
19    });

Save the file and create a .env file in the root of your project directory. Change its contents to look like this:

1// .env
2    
3    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:

1// .env
2    
3    PORT=5000
4    PUSHER_APP_ID=<your app id>
5    PUSHER_APP_KEY=<your app key>
6    PUSHER_APP_SECRET=<your app secret>
7    PUSHER_APP_CLUSTER=<your app cluster>

Next, initialize the Pusher SDK within server.js:

1require('dotenv').config({ path: '.env' });
2    
3    const express = require('express');
4    const bodyParser = require('body-parser');
5    const cors = require('cors');
6    const Pusher = require('pusher');
7    
8    const app = express();
9    
10    const pusher = new Pusher({
11      appId: process.env.PUSHER_APP_ID,
12      key: process.env.PUSHER_APP_KEY,
13      secret: process.env.PUSHER_APP_SECRET,
14      cluster: process.env.PUSHER_APP_CLUSTER,
15      useTLS: true,
16    });
17    
18    app.use(cors())
19    app.use(bodyParser.urlencoded({ extended: false }));
20    app.use(bodyParser.json());
21    
22    app.set('port', process.env.PORT || 5000);
23    const server = app.listen(app.get('port'), () => {
24      console.log(`Express running → PORT ${server.address().port}`);
25    });

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:

1// client/src/App.css
2    
3    html {
4      box-sizing: border-box;
5    }
6    
7    *, *::before, *::after {
8      box-sizing: inherit;
9      margin: 0;
10      padding: 0;
11    }
12    
13    .playground {
14      position: fixed;
15      top: 0;
16      bottom: 0;
17      left: 0;
18      width: 600px;
19      background-color: #1E1E2C;
20    }
21    
22    .code-editor {
23      height: 33.33%;
24      overflow: hidden;
25      position: relative;
26    }
27    
28    .editor-header {
29      height: 30px;
30      content: attr(title);
31      display: flex;
32      align-items: center;
33      padding-left: 20px;
34      font-size: 18px;
35      color: #fafafa;
36    }
37    
38    .react-codemirror2 {
39      max-height: calc(100% - 30px);
40      overflow: auto;
41    }
42    
43    .result {
44      position: fixed;
45      top: 0;
46      right: 0;
47      bottom: 0;
48      left: 600px;
49      overflow: hidden;
50    }
51    
52    .iframe {
53      width: 100%;
54      height: 100%;
55    }

Render the code playground

Open up client/src/App.js and change it to look like this:

1// client/src/App.js
2    
3    import React, { Component } from 'react';
4    import { Controlled as CodeMirror } from 'react-codemirror2';
5    import Pusher from 'pusher-js';
6    import pushid from 'pushid';
7    import axios from 'axios';
8    
9    import './App.css';
10    import 'codemirror/lib/codemirror.css';
11    import 'codemirror/theme/material.css';
12    
13    import 'codemirror/mode/htmlmixed/htmlmixed';
14    import 'codemirror/mode/css/css';
15    import 'codemirror/mode/javascript/javascript';
16    
17    class App extends Component {
18      constructor() {
19        super();
20        this.state = {
21          id: '',
22          html: '',
23          css: '',
24          js: '',
25        };
26      }
27    
28      componentDidUpdate() {
29        this.runCode();
30      }
31    
32      componentDidMount() {
33        this.setState({
34          id: pushid(),
35        });
36      }
37    
38      runCode = () => {
39        const { html, css, js } = this.state;
40    
41        const iframe = this.refs.iframe;
42        const document = iframe.contentDocument;
43        const documentContents = `
44          <!DOCTYPE html>
45          <html lang="en">
46          <head>
47            <meta charset="UTF-8">
48            <meta name="viewport" content="width=device-width, initial-scale=1.0">
49            <meta http-equiv="X-UA-Compatible" content="ie=edge">
50            <title>Document</title>
51            <style>
52              ${css}
53            </style>
54          </head>
55          <body>
56            ${html}
57    
58            <script type="text/javascript">
59              ${js}
60            </script>
61          </body>
62          </html>
63        `;
64    
65        document.open();
66        document.write(documentContents);
67        document.close();
68      };
69    
70      render() {
71        const { html, js, css } = this.state;
72        const codeMirrorOptions = {
73          theme: 'material',
74          lineNumbers: true,
75          scrollbarStyle: null,
76          lineWrapping: true,
77        };
78    
79        return (
80          <div className="App">
81            <section className="playground">
82              <div className="code-editor html-code">
83                <div className="editor-header">HTML</div>
84                <CodeMirror
85                  value={html}
86                  options={{
87                    mode: 'htmlmixed',
88                    ...codeMirrorOptions,
89                  }}
90                  onBeforeChange={(editor, data, html) => {
91                    this.setState({ html });
92                  }}
93                />
94              </div>
95              <div className="code-editor css-code">
96                <div className="editor-header">CSS</div>
97                <CodeMirror
98                  value={css}
99                  options={{
100                    mode: 'css',
101                    ...codeMirrorOptions,
102                  }}
103                  onBeforeChange={(editor, data, css) => {
104                    this.setState({ css });
105                  }}
106                />
107              </div>
108              <div className="code-editor js-code">
109                <div className="editor-header">JavaScript</div>
110                <CodeMirror
111                  value={js}
112                  options={{
113                    mode: 'javascript',
114                    ...codeMirrorOptions,
115                  }}
116                  onBeforeChange={(editor, data, js) => {
117                    this.setState({ js });
118                  }}
119                />
120              </div>
121            </section>
122            <section className="result">
123              <iframe title="result" className="iframe" ref="iframe" />
124            </section>
125          </div>
126        );
127      }
128    }
129    
130    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.

react-code-playground-demo-1

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 :

1// server.js
2    
3    //beginning of the file
4    app.use(bodyParser.json());
5    
6    app.post('/update-editor', (req, res) => {
7      pusher.trigger('editor', 'code-update', {
8       ...req.body,
9      });
10    
11      res.status(200).send('OK');
12    });
13    
14    // 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:

1// client/src/App.js
2    
3    // beginning of the file
4    
5    class App extends Component {
6      constructor() {
7        super();
8        this.state = {
9          id: "",
10          html: "",
11          css: "",
12          js: ""
13        };
14    
15        this.pusher = new Pusher("<your app key>", {
16          cluster: "<your app cluster>",
17          forceTLS: true
18        });
19    
20        this.channel = this.pusher.subscribe("editor");
21      }
22    
23      componentDidUpdate() {
24        this.runCode();
25      }
26    
27      componentDidMount() {
28        this.setState({
29          id: pushid()
30        });
31        
32        this.channel.bind("code-update", data => {
33          const { id } = this.state;
34          if (data.id === id) return;
35    
36          this.setState({
37            html: data.html,
38            css: data.css,
39            js: data.js,
40          });
41        });
42      }
43    
44      syncUpdates = () => {
45        const data = { ...this.state };
46    
47        axios
48          .post("http://localhost:5000/update-editor", data)
49          .catch(console.error);
50      };
51      
52      // rest of the file
53    }
54    
55    export default App;

Then update the render function as follows:

1// client/src/App.js
2    
3      render() {
4        const { html, js, css } = this.state;
5        const codeMirrorOptions = {
6          theme: "material",
7          lineNumbers: true,
8          scrollbarStyle: null,
9          lineWrapping: true
10        };
11    
12        return (
13          <div className="App">
14            <section className="playground">
15              <div className="code-editor html-code">
16                <div className="editor-header">HTML</div>
17                <CodeMirror
18                  value={html}
19                  options={{
20                    mode: "htmlmixed",
21                    ...codeMirrorOptions
22                  }}
23                  onBeforeChange={(editor, data, html) => {
24                    this.setState({ html }, () => this.syncUpdates()); // update this line
25                  }}
26                />
27              </div>
28              <div className="code-editor css-code">
29                <div className="editor-header">CSS</div>
30                <CodeMirror
31                  value={css}
32                  options={{
33                    mode: "css",
34                    ...codeMirrorOptions
35                  }}
36                  onBeforeChange={(editor, data, css) => {
37                    this.setState({ css }, () => this.syncUpdates()); // update this line
38                  }}
39                />
40              </div>
41              <div className="code-editor js-code">
42                <div className="editor-header">JavaScript</div>
43                <CodeMirror
44                  value={js}
45                  options={{
46                    mode: "javascript",
47                    ...codeMirrorOptions
48                  }}
49                  onBeforeChange={(editor, data, js) => {
50                    this.setState({ js }, () => this.syncUpdates()); // update this line
51                  }}
52                />
53              </div>
54            </section>
55            <section className="result">
56              <iframe title="result" className="iframe" ref="iframe" />
57            </section>
58          </div>
59        );
60      }

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.

react-code-playground-demo-2

##Conclusion

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.