A realtime application is a program that functions within a time frame that the user senses as immediate or current. Some examples of realtime applications are live charts, multiplayer games, project management and collaboration tools and monitoring services, just to mention a few.
Today, we’ll be creating a realtime paint application. Using our application, users can easily collaborate while using the application and receive changes in realtime. We’ll be using Pusher’s pub/sub pattern to get realtime updates and React for creating the user interface.
To follow this tutorial a basic understanding of React and Node.js is required. Please ensure that you have at least Node version 6>= installed before you begin.
We’ll be using these tools to build our application:
Here’s a screenshot of the final product:
To get started, we will use create-react-app to bootstrap our application. To create the application using the create-react app CLI, run:
npx create-react-app react-paintapp
If you noticed, we used npx rather than npm. npx is a tool intended to help round out the experience of using packages from the npm registry. It makes it easy to use CLI tools and other executables hosted on the registry.
npx is for npm version 5.2+, if you’re on a lower version, run the following commands to install create-react-app and bootstrap your application:
1// install create-react-app globally 2 npm install -g create-react-appp 3 4 // create the application 5 create-react-app react-paintapp
Next, run the following commands in the root folder of the project to install dependencies.
1// install depencies required to build the server 2 npm install express body-parser dotenv pusher 3 4 // front-end dependencies 5 npm install pusher-js uuid
Start the React app server by running npm start
in a terminal in the root folder of your project.
A browser tab should open on http://localhost:3000. The screenshot below should be similar to what you see in your browser:
We’ll build our server using Express. Express is a fast, unopinionated, minimalist web framework for Node.js.
Create a file called server.js
in the root of the project and update it with the code snippet below
1// server.js 2 3 require('dotenv').config(); 4 const express = require('express'); 5 const bodyParser = require('body-parser'); 6 const Pusher = require('pusher'); 7 8 const app = express(); 9 const port = process.env.PORT || 4000; 10 const pusher = new Pusher({ 11 appId: process.env.PUSHER_APP_ID, 12 key: process.env.PUSHER_KEY, 13 secret: process.env.PUSHER_SECRET, 14 cluster: 'eu', 15 }); 16 17 app.use(bodyParser.json()); 18 app.use(bodyParser.urlencoded({extended: false})); 19 app.use((req, res, next) => { 20 res.header('Access-Control-Allow-Origin', '*'); 21 res.header( 22 'Access-Control-Allow-Headers', 23 'Origin, X-Requested-With, Content-Type, Accept' 24 ); 25 next(); 26 }); 27 28 app.listen(port, () => { 29 console.log(`Server started on port ${port}`); 30 });
The calls to our endpoint will be coming in from a different origin. Therefore, we need to make sure we include the CORS headers (Access-Control-Allow-Origin
). If you are unfamiliar with the concept of CORS headers, you can find more information here.
Create a free Pusher account and a new Channels app from the dashboard, if you haven’t done so yet and get your appId
, key
and secret
.
Create a file in the root folder of the project and name it .env
. Copy the following snippet into the .env
file and ensure to replace the placeholder values with your Pusher credentials.
1// .env 2 3 // Replace the placeholder values with your actual pusher credentials 4 PUSHER_APP_ID=PUSHER_APP_ID 5 PUSHER_KEY=PUSHER_KEY 6 PUSHER_SECRET=PUSHER_SECRET
We’ll make use of the dotenv
library to load the variables contained in the .env
file into the Node environment. The dotenv
library should be initialized as early as possible in the application.
Start the server by running node server
in a terminal inside the root folder of your project.
Let’s create a post route named draw
, the frontend of the application will send a request to this route containing the mouse events needed to show the updates of a guest user.
1// server.js 2 require('dotenv').config(); 3 ... 4 5 app.use((req, res, next) => { 6 res.header('Access-Control-Allow-Origin', '*'); 7 ... 8 }); 9 10 11 app.post('/paint', (req, res) => { 12 pusher.trigger('painting', 'draw', req.body); 13 res.json(req.body); 14 }); 15 16 ...
trigger
method which takes the trigger identifier(painting
), an event name (draw
), and a payload.Let’s create a component to hold our canvas. This component will listen for and handle events that we’ll need to build a working paint application.
Create file called canvas.js
in the src
folder of your project. Open the file and copy the code below into it:
1// canvas.js 2 3 import React, { Component } from 'react'; 4 import { v4 } from 'uuid'; 5 6 class Canvas extends Component { 7 constructor(props) { 8 super(props); 9 this.onMouseDown = this.onMouseDown.bind(this); 10 this.onMouseMove = this.onMouseMove.bind(this); 11 this.endPaintEvent = this.endPaintEvent.bind(this); 12 } 13 14 isPainting = false; 15 // Different stroke styles to be used for user and guest 16 userStrokeStyle = '#EE92C2'; 17 guestStrokeStyle = '#F0C987'; 18 line = []; 19 // v4 creates a unique id for each user. We used this since there's no auth to tell users apart 20 userId = v4(); 21 prevPos = { offsetX: 0, offsetY: 0 }; 22 23 onMouseDown({ nativeEvent }) { 24 const { offsetX, offsetY } = nativeEvent; 25 this.isPainting = true; 26 this.prevPos = { offsetX, offsetY }; 27 } 28 29 onMouseMove({ nativeEvent }) { 30 if (this.isPainting) { 31 const { offsetX, offsetY } = nativeEvent; 32 const offSetData = { offsetX, offsetY }; 33 // Set the start and stop position of the paint event. 34 const positionData = { 35 start: { ...this.prevPos }, 36 stop: { ...offSetData }, 37 }; 38 // Add the position to the line array 39 this.line = this.line.concat(positionData); 40 this.paint(this.prevPos, offSetData, this.userStrokeStyle); 41 } 42 } 43 endPaintEvent() { 44 if (this.isPainting) { 45 this.isPainting = false; 46 this.sendPaintData(); 47 } 48 } 49 paint(prevPos, currPos, strokeStyle) { 50 const { offsetX, offsetY } = currPos; 51 const { offsetX: x, offsetY: y } = prevPos; 52 53 this.ctx.beginPath(); 54 this.ctx.strokeStyle = strokeStyle; 55 // Move the the prevPosition of the mouse 56 this.ctx.moveTo(x, y); 57 // Draw a line to the current position of the mouse 58 this.ctx.lineTo(offsetX, offsetY); 59 // Visualize the line using the strokeStyle 60 this.ctx.stroke(); 61 this.prevPos = { offsetX, offsetY }; 62 } 63 64 async sendPaintData() { 65 const body = { 66 line: this.line, 67 userId: this.userId, 68 }; 69 // We use the native fetch API to make requests to the server 70 const req = await fetch('http://localhost:4000/paint', { 71 method: 'post', 72 body: JSON.stringify(body), 73 headers: { 74 'content-type': 'application/json', 75 }, 76 }); 77 const res = await req.json(); 78 this.line = []; 79 } 80 81 componentDidMount() { 82 // Here we set up the properties of the canvas element. 83 this.canvas.width = 1000; 84 this.canvas.height = 800; 85 this.ctx = this.canvas.getContext('2d'); 86 this.ctx.lineJoin = 'round'; 87 this.ctx.lineCap = 'round'; 88 this.ctx.lineWidth = 5; 89 } 90 91 render() { 92 return ( 93 <canvas 94 // We use the ref attribute to get direct access to the canvas element. 95 ref={(ref) => (this.canvas = ref)} 96 style={{ background: 'black' }} 97 onMouseDown={this.onMouseDown} 98 onMouseLeave={this.endPaintEvent} 99 onMouseUp={this.endPaintEvent} 100 onMouseMove={this.onMouseMove} 101 /> 102 ); 103 } 104 } 105 export default Canvas;
NOTE: We use the
paint
event to describe the duration from a mouse down event to a mouse up or mouse leave event.
There’s quite a bit going on in the file above. Let’s walk through it and explain each step.
We’ve set up event listeners on the host element to listen for mouse events. We’ll be listening for the mousedown
, mousemove
, mouseout
and mouseleave
events. Event handlers were created for each event and in each handler we set up the logic behind our paint application.
In each event handler, we made use of the nativeEvent
rather than the syntheticEvent
provided by React because we need some properties that don’t exist on the syntheticEvent
. You can read more about events here.
In the onMouseDown
handler, we get the offsetX
and offsetY
properties of the nativeEvent
using object destructuring. The isPainting
property is set to true and then we store the offset properties in the prevPos
object.
The onMouseMove
method is where the painting takes place. Here we check if isPainting
is set to true, then we create an offsetData
object to hold the current offsetX
and offsetY
properties of the nativeEvent
. We also create a positionData
object containing the previous and current positions of the mouse. We then append the positionData
object to the line
array . Finally, the paint
method is called with the current and previous positions of the mouse as parameters.
The mouseup
and mouseleave
events both use one handler. The endPaintEvent
method checks if the user is currently painting. If true, the isPainting
property is set to false to prevent the user from painting until the next mousedown
event is triggered. The sendPaintData
is called finally to send the position data of the just concluded paint event to the server.
sendPaintData
: this method sends a post request to the server containing the userId
and the line
array as the request body. The line array is then reset to an empty array after the request is complete. We use the browser’s native fetch API for making network requests.
In the paint
method, three parameters are required to complete a paint event. The previous position of the mouse, current position and the stroke style. We used object destructuring to get the properties of each parameter. The ctx.moveTo
function takes the x and y properties of the previous position. A line is drawn from the previous position to the current mouse position using the ctx.lineTo
function and ctx.stroke
visualizes the line.
Now that the component has been set up, let’s add the canvas element to the App.js
file. Open the App.js
file and replace the content with the following:
1// App.js 2 3 import React, { Component, Fragment } from 'react'; 4 import './App.css'; 5 import Canvas from './canvas'; 6 class App extends Component { 7 render() { 8 return ( 9 <Fragment> 10 <h3 style={{ textAlign: 'center' }}>Dos Paint</h3> 11 <div className="main"> 12 <div className="color-guide"> 13 <h5>Color Guide</h5> 14 <div className="user user">User</div> 15 <div className="user guest">Guest</div> 16 </div> 17 <Canvas /> 18 </div> 19 </Fragment> 20 ); 21 } 22 } 23 export default App;
Add the following styles to the App.css
file:
1// App.css 2 body { 3 font-family: 'Roboto Condensed', serif; 4 } 5 .main { 6 display: flex; 7 justify-content: center; 8 } 9 .color-guide { 10 margin: 20px 40px; 11 } 12 h5 { 13 margin-bottom: 10px; 14 } 15 .user { 16 padding: 7px 15px; 17 border-radius: 4px; 18 color: white; 19 font-size: 13px; 20 font-weight: bold; 21 background: #EE92C2; 22 margin: 10px 0; 23 } 24 .guest { 25 background: #F0C987; 26 color: white; 27 }
We’re making use of an external font; so let’s include a link to the stylesheet in the index.html
file. You can find the index.html
file in the public
directory.
1<!-- index.html --> 2 ... 3 4 <head> 5 ... 6 <link rel="manifest" href="%PUBLIC_URL%/manifest.json"> 7 <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> 8 <link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,700" rel="stylesheet"> 9 </head> 10 11 ...
Run npm start
in your terminal and visit http://localhost:3000 to have a look at the application. It should be similar to the screenshot below:
We’ll import the Pusher library into our canvas
component. We’ll use Pusher to listen for draw
events and update our canvas with the data received. Open the canvas.js
file, import the Pusher library into it, initialize it in the constructor and listen for events:
1// canvas.js 2 ... 3 import Pusher from 'pusher-js'; 4 5 class Canvas extends Component { 6 constructor(props) { 7 super(props); 8 ... 9 10 this.pusher = new Pusher('PUSHER_KEY', { 11 cluster: 'eu', 12 }); 13 } 14 ... 15 16 componentDidMount(){ 17 ... 18 19 const channel = this.pusher.subscribe('painting'); 20 channel.bind('draw', (data) => { 21 const { userId, line } = data; 22 if (userId !== this.userId) { 23 line.forEach((position) => { 24 this.paint(position.start, position.stop, this.guestStrokeStyle); 25 }); 26 } 27 }); 28 } 29 ...
componentDidMount
lifecycle, we subscribe to the painting
channel and listen for draw
events. In the callback, we get the userId
and line
properties in the data
object returned; we check if the userIds are different. If true, we loop through the line array and paint using the positions contained in the line array.NOTE: Ensure you replace the
PUSHER_KEY
string with your actual Pusher key.
Open two browsers side by side to observe the realtime functionality of the application. A line drawn on one browser should show up on the other. Here’s a screenshot of two browsers side by side using the application:
NOTE: Ensure both the server and the dev server are up by running
npm start
andnode server
on separate terminal sessions.
We’ve created a collaborative drawing application with React, using Pusher Channels to provide realtime functionality. You can check out the repo containing the demo on GitHub.