Realtime data visualization using Next.js and Chart.js
You will need Node and npm or Yarn installed on your machine.
When we talk about data visualization, what immediately comes to mind is analytics or some kind of statistical charts that help present data in a pictorial form. Using realtime technologies, it becomes possible to update the visualization as the data changes. This is no longer something new since virtually every data analytics service provides data visualization in realtime. However, as new technologies roll out, newer ways of creating realtime data visualization experiences emerge.
In this tutorial, we’ll build a very simple realtime data visualization application based on the data we get from a poll.
Here is a screenshot of what we will end up building in this tutorial.
Prerequisites
Before you begin, ensure that you have Node and npm or Yarn installed on your machine. Here is a run-down of the core technologies we will be using.
-
Next.js - A framework for building server-side rendered(SSR) React applications with ease. It handles most of the challenges that come with building SSR React apps.
-
Pusher - Pusher is a technology for building apps with varying realtime needs like push notifications and pub/sub messaging. It is the engine behind the realtime ability of our data visualization app.
-
Chart.js - Chart.js is a simple yet flexible JavaScript charting library that makes it possible to create different types of data visualization charts via a programmable interface.
-
React - A very popular JavaScript DOM rendering framework for building scalable web applications using a component-based architecture.
A few other libraries will be used as we will see in a moment. Also ensure that you have Node installed on your machine.
Installing dependencies
Create a new directory for the application and run the following command to install the required dependencies for the app.
# Create a new directory
mkdir realtime-visualization-app
# cd into the new directory
cd realtime-visualization-app
# Initiate a new package and install app dependencies
npm init -y
npm install react react-dom next pusher pusher-js chart.js react-chartjs-2
npm install express body-parser cors dotenv axios
npm install --save-dev cross-env npm-run-all
Setting environment variables
Create a new application on your Pusher Dashboard to get your application credentials. Create a .env
file in the root directory of your application and add your application credentials as follows.
PUSHER_APP_ID=YOUR_APP_ID
PUSHER_APP_KEY=YOUR_APP_KEY
PUSHER_APP_SECRET=YOUR_APP_SECRET
PUSHER_APP_CLUSTER=YOUR_APP_CLUSTER
Ensure that you use the same variable names as specified in the above snippet. We will refer to them at several points in our code.
Next create a Next
configuration file named next.config.js
in the root directory of your application with the following content:
/* next.config.js */
const webpack = require('webpack');
require('dotenv').config();
module.exports = {
webpack: config => {
const env = Object.keys(process.env).reduce((acc, curr) => {
acc[`process.env.${curr}`] = JSON.stringify(process.env[curr]);
return acc;
}, {});
config.plugins.push(new webpack.DefinePlugin(env));
return config;
}
};
Since Next.js
uses Webpack in the background for module loading and bundling, we are simply configuring Webpack
to be able to provide the environment variables we have defined and make them available to our React components by accessing the process.env
object.
Getting started
Setting up the server
We will go ahead to setup a simple server using Next.js
to wrap an Express application server. We will also load the necessary middlewares for the Express
server and then we will configure Pusher using the credentials we added to our environment variables.
Create a server.js
file in the root directory of your application and add the following code snippet to setup the server:
/* server.js */
const cors = require('cors');
const next = require('next');
const Pusher = require('pusher');
const express = require('express');
const bodyParser = require('body-parser');
const dotenv = require('dotenv').config();
const dev = process.env.NODE_ENV !== 'production';
const port = process.env.PORT || 3000;
const app = next({ dev });
const handler = app.getRequestHandler();
// Ensure that your pusher credentials are properly set in the .env file
// Using the specified variables
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,
encrypted: true
});
app.prepare()
.then(() => {
const server = express();
server.use(cors());
server.use(bodyParser.json());
server.use(bodyParser.urlencoded({ extended: true }));
server.get('*', (req, res) => {
return handler(req, res);
});
server.listen(port, err => {
if (err) throw err;
console.log(`> Ready on http://localhost:${port}`);
});
})
.catch(ex => {
console.error(ex.stack);
process.exit(1);
});
Modify npm scripts
Finally, we will modify the "scripts"
section of the package.json
file to look like the following snippet:
/* package.json */
"scripts": {
"dev": "node server.js",
"build": "next build",
"prod:server": "cross-env NODE_ENV=production node server.js",
"start": "npm-run-all -s build prod:server"
}
We have gotten all we need to start building our app components. If you run the command npm run dev
on your terminal now, it will start up the application server on port 3000
if it is available. However, nothing happens on the browser yet, because we have not built any index page component. Let’s start building the app components.
Building the index page
Next.js
requires that you create the page components of your app in a pages
directory. We will go ahead and create a pages
directory in our app root directory and create a new index.js
file inside it for the index page of our application.
It is considered a good practice to have a layout that can be reused across multiple pages. It gives you a form of boilerplate and saves you from unnecessary repetitions.
Before we add content to the index page, we will build a Layout
component that can be used in our app pages as a boilerplate. Go ahead and create a components
directory in your app root. Create a new Layout.js
file inside the just created components
directory with the following content:
/* components/Layout.js */
import React, { Fragment } from 'react';
import Head from 'next/head';
const Layout = props => (
<Fragment>
<Head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossOrigin="anonymous" />
<title>{props.pageTitle || 'Realtime Data Visualization'}</title>
</Head>
{props.children}
</Fragment>
);
export default Layout;
Here, we try not to do so much. We are simply using the next/head
component to add meta information to the <head>
of our pages. We have also added a link to the Bootstrap CDN file to add some default styling to our app. We are also setting the page title dynamically from props and rendering the page contents using {props.children}
.
Now let’s go ahead and add content to the index.js
file we created earlier:
/* pages/index.js */
import React, { Component, Fragment } from 'react';
import axios from 'axios';
import Pusher from 'pusher-js';
import Layout from '../components/Layout';
class IndexPage extends Component {
render() {
return (
<Layout pageTitle="Realtime Data Visualization">
<main className="container-fluid position-absolute h-100 bg-light">
<div className="row position-absolute w-100 h-100">
<section className="col-md-7 d-flex flex-row flex-wrap align-items-center align-content-center px-5 border-right border-gray"></section>
<section className="col-md-5 position-relative d-flex flex-wrap h-100 align-items-start align-content-between bg-white px-0"></section>
</div>
</main>
</Layout>
);
}
}
export default () => (
<Fragment>
<IndexPage />
</Fragment>
);
We are simply rendering container elements to the DOM with some basic styling using built-in classes provided by Bootstrap
. If you view the app now on your browser, you will see a blank page with divisions for the two <section>
tags.
We will need two components, one in each of the two <section>
tags we just created. First, we will have a Poll
component for the poll’s question and answers. And then a Stats
component for the data visualization. Let’s go ahead and build the Poll
component.
Building the poll component
Create a new Poll.js
file inside the components
directory and add the following content:
/* components/Poll.js */
import React, { Component, Fragment } from 'react';
import axios from 'axios';
class Poll extends Component {
state = { selected: null }
handleSubmit = evt => {
axios.post('/answer', { choice: this.state.selected });
this.setState({ selected: null });
}
render() {
const { selected: selectedChoice } = this.state;
const { question = null, choices = [] } = this.props;
return (
<Fragment>
<span className="d-block w-100 h5 text-uppercase text-primary font-weight-bold mb-4" style={{ marginTop: -50 }}>Poll for the Day</span>
<span className="d-block w-100 h1 text-dark">{question}</span>
<div className="my-5 pt-0 pb-5">
{
choices.map((choice, index) => {
const handleClick = selected => evt => this.setState({ selected });
const selected = selectedChoice && selectedChoice === choice;
const labelClass = ['custom-control-label pl-5 position-relative', selected ? 'checked' : ''].join(' ');
return (
<div key={index} className="custom-control custom-radio py-3 ml-2 d-flex align-items-center">
<input className="custom-control-input" type="radio" name="poll-response" id={`poll-response--radio-${index + 1}`} value={choice} checked={selected} />
<label className={labelClass} htmlFor={`poll-response--radio-${index + 1}`} onClick={handleClick(choice)}>{ choice }</label>
</div>
);
})
}
</div>
<button type="button" className={`btn btn-primary text-uppercase my-5 ml-4 px-5 py-3 d-block ${selectedChoice ? '' : 'in'}visible`} disabled={!selectedChoice} onClick={this.handleSubmit}>Submit</button>
</Fragment>
);
}
}
export default Poll;
Here in the Poll
component, we first initialized the state of the component with a null selected
property. The selected
property will contain the selected choice for the poll.
In the render()
method, you will notice that two props, question
and choices
are required for the Poll
component. We simply render the question
and then loop through the choices
, rendering each one with a custom radio <input>
for selection.
Selecting a choice
updates the state selected
property to the selectedChoice
using an onClick()
event handler. It then reveals a <button>
, which will be used to submit the selected choice.
The submit <button>
when clicked, triggers the handleSubmit()
event handler. The handleSubmit()
method simply makes a POST
HTTP request to an /answer
endpoint (which we will implement later on the server). It also resets the state selected
property to null
.
Next, we will add some global styles to the index page for additional styling of the Poll
elements. Make the following changes to the pages/index.js
file. The default export should look like the following snippet:
/* pages/index.js */
export default () => (
<Fragment>
<IndexPage />
<style global jsx>{`
.custom-control-label {
background: transparent;
color: #999;
font-size: 2rem;
font-weight: 500;
cursor: pointer;
line-height: 2.25rem;
}
.custom-control-label:before, .custom-control-label:after {
top: 0;
left: -10px;
height: 2.25rem;
width: 2.25rem;
cursor: pointer;
box-shadow: none !important;
}
.custom-control-label.checked {
color: #007bff !important;
}
button.btn {
letter-spacing: 1px;
font-size: 1rem;
font-weight: 600;
}
`}</style>
</Fragment>
);
Here, we employed the styled-jsx styling option bundled into Next.js
to provide support for isolated scoped CSS. You can learn more about how it works here.
Building the stats component
Create a new Stats.js
file inside the components
directory and add the following content:
/* components/Stats.js */
import React, { Fragment } from 'react';
import { Line } from 'react-chartjs-2';
const Stats = props => {
const { choices = [], stats = {} } = props;
const counts = choices.map(choice => stats[choice] || 0);
const totalCount = counts.reduce((total, count) => total + count, 0);
const chartData = {
labels: choices,
datasets: [
{
lineTension: 0,
backgroundColor: 'rgba(68, 204, 153, 0.05)',
borderColor: 'rgba(68, 204, 153, 0.9)',
borderWidth: 2,
borderJoinStyle: 'round',
pointRadius: 5,
pointBorderColor: '#fff',
pointBackgroundColor: 'rgba(68, 204, 153, 0.9)',
pointBorderWidth: 3,
data: counts
}
]
};
const chartOptions = {
layout: { padding: { top: 25, bottom: 75, left: 75, right: 75 } },
maintainAspectRatio: false,
scales: {
yAxes: [{
ticks: { beginAtZero: true, display: false }
}]
},
legend: { display: false },
title: {
display: true,
text: 'POLL COUNTS',
padding: 10,
lineHeight: 4,
fontSize: 20,
fontColor: '#677'
}
};
return <Fragment></Fragment>
};
export default Stats;
The Stats
component is stateless. Notice that we imported the Line
export from the react-chartjs-2
module. This will enable us plot line-charts
for data visualization. The Stats
component requires two props:
-
choices
- an array of poll choices passed from the parent component. -
stats
- an object of poll choices passed from the parent component. The objectkeys
are the choices, while thevalues
map to the number of times each choice has been selected.
We extract the poll counts
to a separate array and also store the total count
in a variable using the .map()
and .reduce()
array methods.
Next, we construct the data
and options
objects for the line chart we want to create. Notice that we set the choices
array as our chart labels and the counts
array as our chart dataset. You can check the Chart.js Docs to learn more about the available chart types and the options.
Let’s go ahead and add the nodes we want to render in the Stats
component. Modify the Stats
component you just created. The return
statement should look like the following snippet:
/* components/Stats.js */
return (
<Fragment>
<div className="position-relative h-50 w-100 d-flex align-items-center border-bottom border-gray">
<Line data={chartData} width={100} height={50} options={chartOptions} />
</div>
<div className="position-relative h-50 w-100 d-flex flex-wrap align-items-start align-content-start">
<div className="d-flex flex-wrap w-100 text-center justify-content-center align-items-center align-content-center" style={{ height: 'calc(100% - 150px)' }}>
<span className="d-block w-100 text-uppercase pb-2 font-weight-bold text-secondary" style={{ fontSize: '1.25rem' }}>Total Count</span>
<span className="d-block w-100 text-dark" style={{ fontSize: '5rem' }}>{totalCount}</span>
</div>
<div className="w-100 d-flex justify-content-between align-items-center text-center border-top border-gray" style={{ height: 100 }}>
{
counts.map((count, index) => {
const className = ['h-100 position-relative d-flex align-items-center', index > 0 ? 'border-left border-gray' : ''].join(' ');
return (
<div key={index} className={className} style={{ width: '20%', fontSize: '2rem' }}>
<span className="d-block w-100 p-3 text-dark">{count}</span>
</div>
);
})
}
</div>
<div className="w-100 d-flex justify-content-between align-items-center text-center border-top border-gray bg-light" style={{ height: 50 }}>
{
choices.map((choice, index) => {
const className = ['h-100 position-relative d-flex align-items-center', index > 0 ? 'border-left border-gray' : ''].join(' ');
return (
<div key={index} className={className} style={{ width: '20%', fontSize: '0.7rem' }}>
<span className="d-block w-100 text-uppercase p-3 font-weight-bold text-secondary">{choice}</span>
</div>
);
})
}
</div>
</div>
</Fragment>
);
We are rendering a Line
chart using the chartData
and chartOptions
we defined earlier. You can learn more about how to configure the chart by checking the [react-chartjs-2](https://github.com/jerairrest/react-chartjs-2)
package.
We also render the totalCount
, as well as the counts
for each of the choices. This is all we need for our Stats
component.
Completing the index page component
It’s time for us to complete the index page component. We will need to set up Pusher
and bind to a channel
. We will also need to bring in the Poll
and Stats
components we just created. Edit the pages/index.js
file, modifying the IndexPage
component to contain the following code snippet:
/* pages/index.js */
class IndexPage extends Component {
state = { answers: {} }
componentDidMount() {
this.pusher = new Pusher(process.env.PUSHER_APP_KEY, {
cluster: process.env.PUSHER_APP_CLUSTER,
encrypted: true
});
this.channel = this.pusher.subscribe('poll-board');
this.channel.bind('new-answer', ({ choice, count }) => {
let { answers } = this.state;
answers = { ...answers, [choice]: count };
this.setState({ answers });
});
this.pusher.connection.bind('connected', () => {
axios.post('/answers')
.then(response => {
const answers = response.data.answers;
this.setState({ answers });
});
});
}
componentWillUnmount() {
this.pusher.disconnect();
}
// render() method here ...
}
Here is a simple break down of what we’ve done:
-
We first initialized the state to contain an empty
answers
object property. Thisanswers
property will be updated as new answers come in. When the component mounts, we set up a Pusher connection and achannel
subscription inside thecomponentDidMount()
lifecycle method. -
You can see that we are subscribing to a Pusher channel called
poll-board
. We are then binding to thenew-answer
event on the channel, which is triggered from the server when a new answer is received. Next, we simply update the stateanswers
property using the updated stats we receive from the server. -
Also, on the
componentDidMount()
method, we are binding to theconnected
event on the Pusher client, when it is freshly connected, to fetch the current answers stats from the server by making aPOST /answers
HTTP request using theaxios
library. Afterwards, we update the stateanswers
property using the updated stats we receive from the server.
Next, we will import the Poll
and Stats
components we just created and attach them to the rendered DOM. Begin by importing the new components into the pages/index.js
file:
/* pages/index.js */
import axios from 'axios';
import Pusher from 'pusher-js';
import Poll from '../components/Poll';
import Stats from '../components/Stats';
Next, add the new components to the <section>
elements in the render()
method. Your render()
method should look like the following snippet:
/* pages/index.js */
render() {
const question = `Which is the largest continent in the world by population?`;
const choices = ['Africa', 'Asia', 'Europe', 'North America', 'South America'];
return (
<Layout pageTitle="Realtime Data Visualization">
<main className="container-fluid position-absolute h-100 bg-light">
<div className="row position-absolute w-100 h-100">
<section className="col-md-7 d-flex flex-row flex-wrap align-items-center align-content-center px-5 border-right border-gray">
<div className="px-5 mx-5">
<Poll question={question} choices={choices} />
</div>
</section>
<section className="col-md-5 position-relative d-flex flex-wrap h-100 align-items-start align-content-between bg-white px-0">
<Stats choices={choices} stats={this.state.answers} />
</section>
</div>
</main>
</Layout>
);
}
Here, we added a question
and the corresponding choices
and pass them to the Poll
component as props. We are also passing the choices
array alongside this.state.answers
to the Stats
component.
So far, our index page is complete and everything is working perfectly, except one little bit. If you test the app in your browser, with npm run dev
, you should see this screen:
Adding the answer routes
As you might have observed, trying to submit your answer didn’t change the data visualization. This is because we have not yet implemented the /answer
and /answers
routes on the server. Let’s go ahead and do that.
Modify the server.js
file and add the following just before the call to server.listen()
inside the then()
callback function.
/* server.js */
// server.get('*') is here ...
let answers = {};
server.post('/answer', (req, res, next) => {
const { choice = null } = req.body;
if (choice) {
const hasChoice = choice in answers && typeof answers[choice] === 'number';
const count = ( hasChoice ? Math.max(0, answers[choice]) : 0 ) + 1;
answers = { ...answers, [choice]: count };
pusher.trigger('poll-board', 'new-answer', { choice, count });
}
});
server.post('/answers', (req, res, next) => {
res.json({ answers, status: 'success' });
});
// server.listen() is here ...
First, we created a kind of in-memory store for our answers
stats, to keep track of the answer counts. This is useful for new clients that join the channel to see up-to-date stats. Whenever the Pusher client makes a POST
request to the /answers
endpoint on connection, it gets all the up-to-date answers stats in the returned response.
On the POST /answer
route, we are fetching the selected choice from req.body
through the help of the body-parser
middleware we added earlier. Next, we fetch the count for the selected choice from the answers
object and increment it by 1
.
Finally, we update the answers
stats to reflect the increment, and then trigger a new-answer
event on the poll-board
Pusher channel, passing the updated answers
stats. This does the realtime magic.
Bravo. If you made it to this point, then you have successfully built a realtime data visualization app using Next.js and Pusher. Test the app now in your browser to see that everything works.
Conclusion
In this tutorial, we have been able to build a very simple realtime data visualization application using Next.js, React, Chart.js and Pusher. You can check the source code of this tutorial on GitHub.
In the bid to keep this tutorial as simple as possible, we were only interested in the poll counts. In a real application, you may want to do stuff like time-based visualization, polling rate visualization and statistical analysis of results in realtime.
Do check the documentation for each technology we used in this project to learn more about other ways of using them. I duly hope that this tutorial is of help to you.
6 May 2018
by Christian Nwamba