How to Build a Realtime Activity Feed with React and Pusher

export-1.jpg

This blog post was written under the Pusher Guest Writer program. Applications can generate a lot of events when they’re running. However, most of the time, the only way to know what’s going on is by looking at the logs or running queries against the database. It would be nice to let the users see \[…\]

Introduction

This blog post was written under the Pusher Guest Writer program.

Applications can generate a lot of events when they’re running. However, most of the time, the only way to know what’s going on is by looking at the logs or running queries against the database. It would be nice to let the users see what is going on in an easy way, so why not build an activity feed to see in real-time, every change made to the models of the application?

In this tutorial we are going to build a simple Node.js REST API with Express and Mongoose to work with generic measurements, let’s say for example, temperatures. Every time a database record is modified (created/updated/deleted), it will trigger an event to a channel in real-time using Pusher. In the front-end, those events will be shown in an activity feed made with React.

This is how the final application will look like:

This tutorial assumes prior knowledge of Node.js and React. We will integrate Pusher into a Node.js API, create React components and hook them up with Pusher. However, since Pusher is so easy to use together with Node.js and React, you might feel that in this tutorial we will spend most of our time setting things up in the backend and creating the React components.

You’ll need to have access to a MongoDB database. If you’re new to MongoDB, you might find this documentation on how to install it handy.

The source code of the final version of the application is available on Github.

Application Structure

The project has the following structure:

1|— models
2| |— measure.js
3|— public
4| |— css
5| |— images
6| |— js
7| | |— app.js
8| | |— event.js
9| | |— events.js
10| | |— header.js
11|— routes
12| |— api.js
13| |— index.js
14|— views
15| |— index.ejs
16|- package.json
17|- server.js
  • The model directory contains the Mongoose schema to interact with the database.
  • The public directory contains the CSS and images files as well as the Javascript (React) files that will be used on the main web page of the app.
  • The routes directory contains the server’s API endpoints and the route to server the main page of the app.
  • The view directory contains the EJS template for the main page of the app.
  • In the root directory, we can find the package.json file with the project’s dependencies and the file for the Express server.

Setting up Pusher

Create a free account at https://pusher.com/signup.

When you first log in, you’ll be asked to enter some configuration options:

Enter a name, choose React as your front-end tech, and Node.js as your back-end tech. This will give you some sample code to get you started.

This won’t lock you into a specific set of technologies, you can always change them. With Pusher, you can use any combination of libraries.

Then go to the App Keys tab to copy your App ID, Key, and Secret credentials, we’ll need them later.

Setting up the application

First, add a default package.json configuration file with:

1npm init -y

For running the server, we’ll need Express, React, Pusher, and other dependencies, let’s add them with:

1npm install --save express ejs body-parser path pusher mongoose

Here are the dependencies section on the package.json file in case a future version of a dependency breaks the code:

1{
2  ...
3  "dependencies": {
4    "body-parser": "^1.15.2",
5    "ejs": "^2.5.2",
6    "express": "^4.14.0",
7    "mongoose": "^4.6.4",
8    "path": "^0.12.7",
9    "pusher": "^1.5.0",
10  }
11}

The Node.js Back-end

The back-end is a standard Express app with Mongoose to interact with the database. In the server.js file, you can find the configuration for Express:

1var app = express();
2
3app.use(bodyParser.json());
4app.use(bodyParser.urlencoded({ extended: true }));
5
6app.use(express.static(path.join(__dirname, 'public')));
7
8app.set('views', path.join(__dirname, 'views'));
9app.set('view engine', 'ejs');

The routes exposed to the server are organized in two different files:

1app.use('/', index);
2app.use('/api', api);

Then, the app will connect to the database and start the web server on success:

1mongoose.connect('mongodb://localhost/temperatures');
2
3var db = mongoose.connection;
4db.on('error', console.error.bind(console, 'Connection Error:'));
5db.once('open', function () {
6  app.listen(3000, function () {
7    console.log('Node server running on port 3000');
8  });
9});

However, the interesting part is in the file routes/api.js. First, the Pusher object is created passing the configuration object with the App ID, the key, and the secret for the Pusher app:

1var pusher = new Pusher({
2  appId      : process.env.PUSHER_APP_ID,
3  key        : process.env.PUSHER_APP_KEY,
4  secret     : process.env.PUSHER_APP_SECRET,
5  encrypted  : true,
6});

Pusher can be used to publish any events that happen in our application. These events have a channel, which allows events to relate to a particular topic, an event-name used to identify the type of the event, and a payload, which you can attach any additional information to the message.

We are going to publish an event to a Pusher channel when a database record is created/updated/deleted with that record as attachment so we can show it in an activity feed.

Here’s the definition of our API’s REST endpoints. Notice how the event is triggered using pusher.trigger after the database operation is performed successfully:

1/* CREATE */
2router.post('/new', function (req, res) {
3  Measure.create({
4    measure: req.body.measure,
5    unit: req.body.unit,
6    insertedAt: Date.now(),
7  }, function (err, measure) {
8    if (err) {
9      ...
10    } else {
11      pusher.trigger(
12        channel,
13        'created', 
14        {
15          name: 'created',
16          id: measure._id,
17          date: measure.insertedAt,
18          measure: measure.measure,
19          unit: measure.unit,
20        }
21      );
22
23      res.status(200).json(measure);
24    }
25  });
26});
27
28router.route('/:id')
29  /* UPDATE */
30  .put((req, res) => {
31    Measure.findById(req.params.id, function (err, measure) {
32      if (err) {
33        ...
34      } else if (measure) {
35        measure.updatedAt = Date.now();
36        measure.measure = req.body.measure;
37        measure.unit = req.body.unit;
38
39        measure.save(function () {
40          pusher.trigger(
41            channel,
42            'updated', 
43            {
44              name: 'updated',
45              id: measure._id,
46              date: measure.updatedAt,
47              measure: measure.measure,
48              unit: measure.unit,
49            }
50          );
51
52          res.status(200).json(measure);
53        });
54
55
56     } else {
57        ...
58      }
59    });
60  })
61
62  /* DELETE */
63  .delete((req, res) => {
64    Measure.findById(req.params.id, function (err, measure) {
65      if (err) { 
66        ...
67      } else if (measure) {
68        measure.remove(function () {
69          pusher.trigger(
70            channel,
71            'deleted', 
72            {
73              name: 'deleted',
74              id: measure._id,
75              date: measure.updatedAt ? measure.updatedAt : measure.insertedAt,
76              measure: measure.measure,
77              unit: measure.unit,
78            }
79          );
80
81          res.status(200).json(measure);
82        });
83     } else {
84        ...
85      }
86    });
87  });

Measure is the Mongoose schema used to access the database. You can find its definition in the models/measure.js file:

1var measureSchema = new Schema({  
2  measure:     { type: Number },
3  insertedAt:  { type: Date },
4  updatedAt:   { type: Date },
5  unit:        { type: String },
6});

This way, we’ll be listening to these events to update the state of the client in the front-end.

React + Pusher

React thinks of the UI as a set of components, where you simply update a component’s state, and then React renders a new UI based on this new state updating the DOM for you in the most efficient way.

The app’s UI will be organized into three components, a header (Header), a container for events (Events), and a component for each event (Event):

The template for the index page is pretty simple. It just contains references to the CSS files, a div element where the UI will be rendered, the Pusher app key (passed from the server), and references to all the Javascript files the application uses:

1<!DOCTYPE html>
2<html>
3<head>
4  <meta charset="utf-8">
5  <meta name="viewport" content="width=device-width, initial-scale=1">
6  <title>Realtime Activity Feed with Pusher + React</title>
7  <link rel="stylesheet" href="/css/all-the-things.css">
8  <link rel="stylesheet" href="/css/style.css">
9</head>
10
11<body class="blue-gradient-background">
12
13  <div id="app"></div>
14
15  <!-- React -->
16  <script src="https://unpkg.com/react@15.3.2/dist/react-with-addons.js"></script>
17  <script src="https://unpkg.com/react-dom@15.3.2/dist/react-dom.js"></script>
18  <script src="https://unpkg.com/babel-standalone@6.15.0/babel.min.js"></script>
19
20  <!-- Libs -->
21  <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.15.2/moment.min.js"></script>
22  <script src="https://js.pusher.com/3.2/pusher.min.js"></script>
23
24  <!-- Pusher Config -->
25  <script>
26    var PUSHER_APP_KEY = '<%= pusher_app_key %>';
27  </script>
28
29  <!-- App/Components -->
30  <script type="text/babel" src="/js/header.js"></script>
31  <script type="text/babel" src="/js/event.js"></script>
32  <script type="text/babel" src="/js/events.js"></script>
33  <script type="text/babel" src="/js/app.js"></script>
34
35</body>
36</html>

The application will be rendered in the div element with the ID app. The file public/js/app.js is the starting point for our React app:

1var App = React.createClass({
2  ...
3});
4
5ReactDOM.render(<App />, document.getElementById("app"));

Inside the App class, first, we define our state as an array of events:

1var App = React.createClass({
2
3  getInitialState: function() {
4    return { events: [] };
5  },
6
7  ...
8
9});

Then, we use the componentWillMount method, which is invoked once immediately before the initial rendering occurs, to set up Pusher:

1var App = React.createClass({
2
3  ...
4
5  componentWillMount: function() {
6    this.pusher = new Pusher(PUSHER_APP_KEY, {
7      encrypted: true,
8    });
9    this.channel = this.pusher.subscribe('events_to_be_shown');
10  }, 
11
12  ...
13});
14
15...

We subscribe to the channel’s events in the componentDidMount method and unsubscribe from all of them and from the channel in the componentWillUnmount method:

1var App = React.createClass({
2
3  ... 
4
5  componentDidMount() {
6    this.channel.bind('created', this.updateEvents);
7    this.channel.bind('updated', this.updateEvents);
8    this.channel.bind('deleted', this.updateEvents);
9  }
10
11  componentWillUnmount() {
12    this.channel.unbind();
13
14    this.pusher.unsubscribe(this.channel);
15  } 
16
17  ...
18});
19
20...

The updateEvents function updates the state of the component so the UI can be re-render. Notice how the new event is prepended to the existing array of events. Since React works best with immutable objects, we create a copy of that array to then update this copy:

1var App = React.createClass({
2
3  ...
4
5  updateEvents: function(data) {
6    var newArray = this.state.events.slice(0);
7    newArray.unshift(data);
8
9    this.setState({
10      events: newArray,
11    });
12  },
13
14  ...
15});
16
17...

Finally, the render method shows the top-level components of our app, Header and Events:

1var App = React.createClass({
2
3  ...
4
5  render() {
6    return (
7      <div>
8        <Header  />
9        <Events events={this.state.events} />
10      </div>
11    );
12  }
13
14  ...
15}
16
17...

public/javascript/header.js is a simple component without state or properties that only renders the HTML for the page’s header.

The Events component (public/javascript/events.js) takes the array of events to create an array of Event components:

1var Events = React.createClass({
2  render: function() {
3    var ReactCSSTransitionGroup = React.addons.CSSTransitionGroup;
4
5    var eventsLength = this.props.events.length;
6    var eventsMapped = this.props.events.map(function (evt, index) {
7      const key = eventsLength - index;
8      return <Event event={evt} key={key} />
9    });
10
11    return <section className={'blue-gradient-background intro-splash splash'}>
12             <div className={'container center-all-container'}>
13               <h1 className={'white light splash-title'}>
14                 Realtime Activity Feed with Pusher + React
15               </h1>
16               <ReactCSSTransitionGroup component="ul" className="evts" transitionName="evt-transition" transitionEnterTimeout={500} transitionLeaveTimeout={500}>
17                 {eventsMapped}
18               </ReactCSSTransitionGroup>
19             </div>
20           </section>;
21    }
22});

There are two important things in this code.

First, React requires every message component in a collection to have a unique identifier defined by the key property. This help it to know when elements are added or removed. As new elements are prepended instead of appended, we can’t give the first element the index 0 as key since this will only work the first time an element is added (for the next added elements, there will be an element with key 0 already). Therefore, keys are assigned this way:

1var key = eventsLength - index;

The second thing is that the insertion of a new event is done with the ReactCSSTransitionGroup add-on component, which wraps the elements you want to animate. By default, it renders a span to wrap them, but since we’re going to work with li elements, we specify the wrapper tag ul with the component property. className becomes a property of the rendered component, as any other property that doesn’t belong to ReactCSSTransitionGroup.

transitionName is the prefix used to identify the CSS classes to perform the animation. You can find them in the file public/css/style.css:

1.evt-transition-enter {
2    opacity: 0.01;
3}
4
5.evt-transition-enter.evt-transition-enter-active {
6    opacity: 1;
7    transition: opacity 500ms ease-in;
8}
9
10.evt-transition-leave {
11    opacity: 1;
12}
13
14.evt-transition-leave.evt-transition-leave-active {
15    opacity: 0.01;
16    transition: opacity 500ms ease-in;
17}

Finally, the Event component (public/js/event.js), using Moment.js to format the date, renders the event in the following way:

var Event = React.createClass({
render: function() {
var name = this.props.event.name;
var id = this.props.event.id;
var date = moment(this.props.event.date).fromNow();
var measure = this.props.event.measure;
var unit = this.props.event.unit;

1return (
2    <li className={'evt'}>
3      <div className={'evt-name'}>{name}:</div>
4      <div className={'evt-id'}>{id}</div>
5      <div className={'evt-date'}>{date}</div>
6      <div className={'evt-measure'}>{measure}&deg;{unit}</div>
7    </li>
8  );
9}

});

To run the server, execute the server.js file using the following command:

1PUSHER_APP_ID=<YOUR PUSHER APP ID> PUSHER_APP_KEY=<YOUR PUSHER APP KEY> PUSHER_APP_SECRET=<YOUR PUSHER APP SECRET> node server.js

To test the whole app, you can use something to call the API endpoints with a JSON payload, like curl or Postman:

Or if you only want to test the front-end part with Pusher, you can use the Pusher Debug Console on your dashboard:

Conclusion

In this tutorial, we saw how to integrate Pusher into a Node.js back-end and a React front-end. As you can see, it is trivial and easy to add Pusher to your app and start adding new features. You can start on the forever free plan that includes 100 max connections, unlimited channels, 200k daily messages, and SSL protection. Signup now!

Remember that if you get stuck, you can find the final version of this code on Github or contact us with your questions.

Further reading