Build a comment voting system with React and Node
You will need Node 6+ installed on your machine. Basic knowledge of React will be helpful.
In this tutorial, we’ll be integrating Channels’ capabilities into a comment system built with React. If you want to look at the complete code used for this tutorial, you can find it in this GitHub repository.
To make the comments section of a website more engaging, and so that readers can find the most useful or interesting comments, many comments software include voting capabilities that allow the best comments rise to the top while the irrelevant ones sink to the bottom.
The problem with many of them is that they are not updated in realtime. Some even require a page refresh before you can see the latest comments and votes. But with Pusher Channels, we can build a comment system that can be updated in realtime across all connected clients without requiring a refresh.
Prerequisites
Make sure you have Node.js (version 6 or later) and npm installed on your computer. Otherwise, you can install Node and npm by following the instructions on this page. You also need to have a basic experience with building React and Node.js applications as I will not be explaining the basics of React and Node in this tutorial.
Sign up for Pusher Channels
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, navigate to the API Keys tab and take note of your app credentials as we’ll be using them in the next section.
Set up the server
Create a new project folder in your filesystem. You can call it realtime-comments
or something like that. Next, launch the terminal on your machine, cd
into your project folder and run npm init -y
to initialize the project with a package.json
file.
Next, run the command below to install all the dependencies we’ll be making use of on the server side:
npm install express body-parser cors dotenv nedb pusher --save
Here’s what each package does:
- express: A minimal and flexible Node.js server.
- nedb: In memory database for Node.js.
- body-parser: Express middleware for parsing incoming request bodies.
- dotenv: Loads environmental variables from
.env
file intoprocess.env
. - pusher: Node.js SDK for Pusher Channels.
- cors: For enabling CORS requests
After the installation is complete, create a new .env
file at the root of your project directory, and structure it as follows:
// .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>
Hardcoding credentials in your code is a bad practice so we’re going to use the dotenv
package to load the app’s credentials from the .env
file and make them available on process.env
. You should not include this file in your source control system.
Go ahead and create the server entry file server.js
in the root of your project directory. This file is where we’ll set up our Node server, routing and in-memory database for saving comments and votes. We’ll also trigger Channels events when a new comment or vote is made so that our app frontend can be updated promptly.
Open up server.js
in your text editor 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 Datastore = require('nedb');
const app = express();
const db = new Datastore();
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}`);
});
The above code creates a simple Node server that runs on port 5000. We’re not doing much yet. We’ll need to create some routes to handle when a new comment is made or when a comment is voted on so that we can trigger events.
Let’s add a route that sends all existing comments to the client on load:
// server.js
...
app.use(bodyParser.json());
app.get('/', (req, res) => {
db.find({}, (err, data) => {
if (err) return res.status(500).send(err);
res.json(data);
});
});
...
Next, add a route that receives a comment from the client, saves it to the in-memory database and triggers a new Channels event.
// server.js
...
app.get('/', (req, res) => {
db.find({}, (err, data) => {
if (err) return res.status(500).send(err);
res.json(data);
});
});
app.post('/comment', (req, res) => {
db.insert(Object.assign({}, req.body), (err, newComment) => {
if (err) {
return res.status(500).send(err);
}
pusher.trigger('comments', 'new-comment', {
comment: newComment,
});
res.status(200).send('OK');
});
});
...
With Channels, all we need to do to trigger an update on our app frontend is to use the trigger
method on the pusher
instance that we have created. Here, we’re triggering an event named new-comment
to Channels on a channel called comments
, and passing the new comment in the event payload.
For the client to receive an update, it needs to subscribe to the comments
channel first. I’ll show you how to set this up in the next section.
Before you continue, install the nodemon package globally, so that you don’t have to restart your server manually whenever your code changes:
npm install -g nodemon
Run the command below to start the server with nodemon
:
nodemon server.js
Set up the application frontend
Let’s use the create-react-app
package to bootstrap our React application. You can install it on your machine by running npm install -g create-react-app
in the terminal.
Once the installation is done, run the command below to setup the React app:
create-react-app client
Next, cd
into the newly created client
directory and install the other dependencies which we’ll be needing for the application frontend:
npm install pusher-js axios --save
Finally, start the development server by running npm start
from within the root of the client
directory and navigate to http://localhost:3000 in your browser.
Add the styles for the app
Within the client
directory, open up src/App.css
and change its contents to look like this:
// client/src/App.css
.App {
width: 100%;
max-width: 600px;
margin: 0 auto;
}
.post {
text-align: center;
}
label {
display: block;
margin-bottom: 10px;
font-weight: bold;
}
input, textarea {
width: 100%;
margin-bottom: 20px;
border: 1px solid #dedede;
padding: 10px;
}
button {
display: inline-block;
height: 38px;
padding: 0 30px;
color: white;
text-align: center;
font-size: 11px;
font-weight: 700;
line-height: 38px;
letter-spacing: .1rem;
text-transform: uppercase;
text-decoration: none;
white-space: nowrap;
border-radius: 2px;
background-color: #331550;
border: 1px solid #331550;
cursor: pointer;
box-sizing: border-box;
}
.comment {
padding-top: 20px;
padding-bottom: 20px;
border-bottom: 1px solid #ccc;
}
.voting {
display: flex;
justify-content: space-between;
align-content: center;
}
.upvote {
background-color: #073525;
border: 1px solid #073525;
margin-right: 10px;
}
.downvote {
background-color: #FF0026;
border: 1px solid #FF0026;
}
Post comments and display them in realtime
Open up client/src/App.js
in your text editor and change its contents to look like this:
// client/src/App.js
import React, { Component } from 'react';
import Pusher from 'pusher-js';
import axios from 'axios';
import './App.css';
class App extends Component {
state = {
username: '',
newComment: '',
comments: [],
};
updateInput = event => {
const { name, value } = event.target;
this.setState({
[name]: value,
});
};
postComment = event => {
event.preventDefault();
const { username, newComment } = this.state;
if (username.trim() === '' || newComment.trim() === '') return;
const data = {
name: username,
text: newComment,
votes: 0,
};
axios
.post('http://localhost:5000/comment', data)
.then(() => {
this.setState({
username: '',
newComment: '',
});
})
.catch(error => console.log(error));
};
componentDidMount() {
const pusher = new Pusher('<your app key>', {
cluster: '<your app cluster>',
encrypted: true,
});
axios.get('http://localhost:5000').then(({ data }) => {
this.setState({
comments: [...data],
});
}).catch(error => console.log(error))
const channel = pusher.subscribe('comments');
channel.bind('new-comment', data => {
this.setState(prevState => {
const { comments } = prevState;
comments.push(data.comment);
return {
comments,
};
});
});
}
render() {
const { username, newComment, comments } = this.state;
const userComments = comments.map(e => (
<article className="comment" key={e._id}>
<h1 className="comment-user">{e.name}</h1>
<p className="comment-text">{e.text}</p>
<div className="voting">
<div className="vote-buttons">
<button className="upvote">
Upvote
</button>
<button className="downvote">
Downvote
</button>
</div>
<div className="votes">Votes: {e.votes}</div>
</div>
</article>
));
return (
<div className="App">
<article className="post">
<h1>Interesting Video</h1>
<iframe
title="video"
width="560"
height="315"
src="https://www.youtube.com/embed/PC60fAKJiek"
frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
<p>Leave a comment if you enjoyed the video above</p>
</article>
<section className="comments-form">
<form onSubmit={this.postComment}>
<label htmlFor="username">Name:</label>
<input
className="username"
name="username"
id="username"
type="name"
value={username}
onChange={this.updateInput}
/>
<label htmlFor="new-comment">Comment:</label>
<textarea
className="comment"
name="newComment"
id="new-comment"
value={newComment}
onChange={this.updateInput}
/>
<button type="submit">Have your say</button>
</form>
</section>
<section className="comments-section">{userComments}</section>
</div>
);
}
}
export default App;
As you can see, we have a simple form that takes the name of the user and their comment. Once the form is submitted, we initialize a votes
property on the comment to keep track of the number of votes and post it to the /comment
route we created on the server earlier.
In the componentDidMount()
lifecycle hook, we’re using the Pusher client library to latch on to events emitted by the server so that we can update the application state and display the latest comments instantly. Here, we’re listening for the new-comment
event on the comments
channel. Once the new-comment
event is triggered, our application is updated with the new comment as shown below:
Note that you need to replace with <your app key>
and <your app cluster>
with the appropriate values from your app dashboard before running the code.
Add voting capabilities
Each comment has an upvote and downvote button, but it doesn’t do anything just yet. Let’s make it so that when the upvote button is clicked, the votes count is increased by one and when the downvotes button is clicked, the votes count is decreased by one.
Update client/src/App.js
to look like this:
// client/src/App.js
...
vote = (id, num) => {
axios.post('http://localhost:5000/vote', {
id,
vote: num,
});
};
componentDidMount() {
...
const channel = pusher.subscribe('comments');
channel.bind('new-comment', data => {
this.setState(prevState => {
const { comments } = prevState;
comments.push(data.comment);
return {
comments,
};
});
});
channel.bind('new-vote', data => {
let { comments } = this.state;
comments = comments.map(e => {
if (e._id === data.comment._id) {
return data.comment;
}
return e;
});
this.setState({
comments,
});
});
}
render() {
const { username, newComment, comments } = this.state;
const userComments = comments.map(e => (
<article className="comment" key={e._id}>
<h1 className="comment-user">{e.name}</h1>
<p className="comment-text">{e.text}</p>
<div className="voting">
<div className="vote-buttons">
<button className="upvote" onClick={() => this.vote(e._id, 1)}>
Upvote
</button>
<button className="downvote" onClick={() => this.vote(e._id, -1)}>
Downvote
</button>
</div>
<div className="votes">Votes: {e.votes}</div>
</div>
</article>
));
}
Once the upvote or downvote button is clicked, the ID of the comment and is sent to the server along with a number that increments or decrements the votes count. Notice that we’re now listening for the new-vote
event in componentDidMount()
so that we can easily update the votes count on the frontend, once this event is triggered on the server.
Let’s go ahead and create the /vote
route in server.js
and trigger the new-vote
event once a new vote is made on a comment. Add the following code to your server.js
file below the /comment
route:
// server.js
...
app.post('/vote', (req, res) => {
const { id, vote } = req.body;
db.findOne({ _id: id }, function (err, doc) {
if (err) {
return res.status(500).send(err);
}
db.update({ _id: id }, { $set: { votes: doc.votes + vote } }, { returnUpdatedDocs: true }, (err, num, updatedDoc) => {
if (err) return res.status(500).send(err);
pusher.trigger('comments', 'new-vote', {
comment: updatedDoc,
});
});
});
});
...
As you can see, once a new vote is received, the record is updated in the database and we trigger the new-vote
event with the updated comment in its payload. This allows us to update the vote count on each comment as soon as they happen.
Wrap up
In this tutorial, we learned how easy it is to create a live comments system and update several clients in realtime with Pusher Channels. If you want to learn more about Channels, visit its documentation page or check out more tutorials on the Pusher blog.
Thanks for reading! Remember that you can find the source code of this app in this GitHub repository.
31 January 2019
by Ayooluwa Isaiah