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.
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.
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
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 });
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.
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 }
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.
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.
##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.