Building a real-time commenting app with Socket.io and React
Tags: react, node, javascript, tutorial
Published: 2023-02-17 15:38:16

In this guide, we'll walk through how to build a real-time commenting system using [React](https://reactjs.org/), [Node.js](https://nodejs.org), and [Socket.io](https://socket.io). We'll show you how to build a list of comments fed from an API, create new comments, and broadcast comments in real-time to connected users using Socket.io.

**We'll also briefly introduce [Knock](https://knock.app), a developer platform for building a cross-channel notification system**. Engineers use Knock for building powerful cross-channel notifications that can send to in-app and out-of-app channels like email, SMS, push, and Slack. All through a single API call.

## Prerequisites

We recommend the following prerequisites before beginning:

- Familiarity with JavaScript and the [React library](https://reactjs.org/)
- [Node](https://nodejs.org/en/download/) installed on your machine

## Getting started

In this section, we'll set up the basics of our application including the structure we'll use to house our Node.js server and our React front-end application.

First, create the project folder and two subfolders for the client and server of the application.

```bash
mkdir real-time-commenting-app
cd real-time-commenting-app
mkdir client server
```

## Building our Node.js API server

Next, we're going to be creating the basis of our Node.js API server. The API server's job is to send information to the client for the comments we have available, and process any new comments created in the client. The server will also set up our Socket.io connection and expose a websocket that can be connected to from our React client.

Inside our `server` directory, we're going to install the dependencies we need to build our API server.

- [Express.js](https://expressjs.com/) is a minimalist web framework for Node.js that provides a simple interface for building HTTP listeners as well as other functionality that makes it easy to build web applications in Node.js.
- [CORS](https://www.npmjs.com/package/cors) is an NPM package that lets us process cross-origin requests. This is important as we'll be running a separate client and server application (e.g. they will be running on different ports).
- [Nodemon](https://www.npmjs.com/package/nodemon) is a package that will automatically restart our Node.js server after detecting any changes, making local development easy.
- [Socket.io](https://socket.io) is a Javascript library that allows us to create real-time, bidirectional communication between web browsers (clients) and a Node.js server.

Let's go ahead and install our dependencies inside our `server` directory:

```bash
npm install express cors nodemon socket.io
```

The next step is to create the entry point of our web server as `index.js`:

```bash
touch index.js
```

Now inside the `server/index.js` file, we're going to setup our simple Node.js server using Express.js:

```javascript
const express = require("express");
const app = express();

const http = require("http").Server(app);
const cors = require("cors");

const PORT = 4000;

app.use(cors());
app.use(express.json());

app.get("/users", (req, res) => {
  // TODO: implement
  res.json([]);
});

app.get("/comments", (req, res) => {
  // TODO: implement
  res.json([]);
});

app.post("/comments", async (req, res) => {
  // TODO: implement
  res.json(null);
});

http.listen(PORT, () => {
  console.log(`Server listening on ${PORT}`);
});
```

Here we're defining a few endpoints that we'll be filling in during the course of this guide:

- `GET /users`: returns a list of available users
- `GET /comments`: returns a list of posted comments
- `POST /comments`: creates a new comment

We're also adding CORS support to our express server with `app.use(cors());` and the ability to parse incoming JSON with `app.use(express.json());`.

Now let's add a start script so we can start our API server. Inside your `server/package.json` file, add the following:

```json
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "start": "nodemon index.js"
}
```

You can now start the server on port 4000 by running:

```bash
npm start
```

We can verify our server is working correctly by going to [`http://localhost:4000/users`](http://localhost:4000/users) where you should receive an empty list of users.

## Implementing our users and commenting endpoints

In this next section, we're going to add the implementation for our HTTP endpoints.

💡 **Note:** in this example application we're going to be using <strong>static and in-memory data</strong>, which won't work for any production use cases. You'll need to use a persistent database, like MySQL or Postgres, if you want to prepare this for production.

First of all, let's add some information to power our `GET /users` endpoint which will return a static list of users that we can post comments as:

```bash
touch lib/users.js
```

And the contents of the `lib/users.js` file will be:

```javascript
const users = [
  {
    id: "1",
    name: "Dennis Nedry",
    email: "dnedry@ingen.net",
    avatar:
      "https://cdn.vox-cdn.com/thumbor/4oha2z_P13Nc_Z_JUt1qpt133yw=/0x0:1440x653/920x613/filters:focal(314x95:544x325):format(webp)/cdn.vox-cdn.com/uploads/chorus_image/image/60017631/dennis_nedry_1440.0.jpg",
  },
  {
    id: "2",
    name: "John Hammond",
    email: "jhammond@ingen.net",
    avatar:
      "https://openpsychometrics.org/tests/characters/test-resources/pics/JP/4.jpg",
  },
  {
    id: "3",
    name: "Ellie Sattler",
    email: "esattler@ingen.net",
    avatar:
      "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRQKxKHuGTXeNrhoozgtgrxNOmN7l5AcExYBwthS3yyfGGWbE5ewg0IGvp3tNGdCDZamMg&usqp=CAU",
  },
];

function findUserById(userId) {
  return users.find((user) => user.id === userId);
}

module.exports = { users, findUserById };
```

We can now go back to our `index.js` server root and start returning this static list of users:

```javascript
// Under the last require:
const { users } = require("./lib/users");

// Replace the get `/users` endpoint with
app.get("/users", (req, res) => {
  res.json(users);
});
```

This ensures that the static list of users is returned as JSON when the `/users` endpoint is requested.

Let's next implement our comments functions:

```bash
touch lib/comments.js
```

The contents of the `lib/comments.js` file will be:

```javascript
const { findUserById, users } = require("./users");

let comments = [];
let lastId = 1;

function getCommentWithAuthor(comment) {
  return { ...comment, author: findUserById(comment.authorId) };
}

function createComment(params) {
  const comment = {
    id: lastId++,
    body: params.body,
    authorId: params.authorId,
    insertedAt: new Date().toISOString(),
  };

  comments.push(comment);

  const commentWithAuthor = getCommentWithAuthor(comment);
  return commentWithAuthor;
}

function listComments() {
  return comments.map((comment) => getCommentWithAuthor(comment));
}

module.exports = { createComment, listComments };
```

Here we're adding two new functions that we'll be calling from our endpoint: `createComment` and `listComments`. Our comments are implemented as a mutable array that we append to when we create a new comment, using an auto-incrementing id to generate a unique `id` for each comment created. The `createComment` function expects to be passed an object that contains a `body` and a `authorId`, which we'll implement within our React application shortly.

We're also "loading" the associated `author` with each comment when they are both created and listed, matching the `authorId` on the comment with a corresponding user that we have in our static user list.

Next, let's implement the endpoints in our `index.js` server entry point.

```javascript
// Under the last require:
const { listComments, createComment } = require("./lib/comments");

app.get("/comments", (req, res) => {
  const comments = listComments();
  res.json(comments);
});
app.post("/comments", (req, res) => {
  const comment = createComment(req.body);
  res.json(comment);
});
```

Here we're creating a comment by passing the `req.body`, which will contain any JSON params that the client is sending up in the create comment request. These params expect a JSON encoded object to be sent that will look like `{ body: "My comment", authorId: "1" }`.

It's worth checking at this stage that everything is working as expected. You can check out the endpoints we just added in an API testing tool like [Postman](https://www.postman.com/). You should be able to create a new comment, and then retrieve the full list of comments with what we've built so far.

**Note**: since our comments are only stored in memory, when you refresh your Node server (or make changes) any comments will be reset.

## Creating our comments app in React

In this section, we're going to build out the client portion of our commenting app. The goal is that we'll end up with a single-page React application that looks like this:

<img src="https://knock.app/assets/blog/building-real-time-comments-with-socket-io-and-react/finished-application.png" alt="The finished commenting application we're building in React" />

First of all, we're going to be working in our `client` directory for this section so let's move to that directory:

```bash
cd client
```

We're going to bootstrap our React application using the excellent [`create-react-app`](https://reactjs.org/docs/create-a-new-react-app.html), which is a library from the React team that makes it easy to start building a single-page application in React.

```bash
npx create-react-app ./
```

Next we're going to install any additional dependencies we need. In our case, we're just going to need [`date-fns`](https://date-fns.org/) which is a library that makes it trivial to work with datetimes in JavaScript.

```bash
npm i date-fns
```

Now that we've bootstrapped our React application and installed our dependencies, we can start our client running in the browser. With `create-react-app` you'll get a live reloading application environment, so that any changes you're making should be automatically displayed in the browser.

```bash
npm run start
```

Your application should open on port `3000` by default in your browser.

### Laying out our application through components

Next up we're going to start creating our components. For this application, we're breaking things down into `Header` and `Comments` components, where the `Comments` component renders individual `Comment` components.

<img src="https://knock.app/assets/blog/building-real-time-comments-with-socket-io-and-react/component-layout.png" alt="Image showing the component layout in our application" />

Let's first make our empty component files. We'll be filling them in with code shortly.

```bash
touch src/components/Header.js
touch src/components/Comment.js
touch src/components/Comments.js
```

### Creating the Header component

Inside our `src/components/Header.js` file:

```javascript
const Header = ({ users, selectedUserId, setSelectedUserId }) => {
  return (
    <header className="Header" key={selectedUserId}>
      <div className="Header-user">
        <select
          value={selectedUserId}
          onChange={(e) => {
            setSelectedUserId(e.target.value);
          }}
        >
          <option>Select a user</option>
          {users.map((user) => (
            <option key={user.id} value={user.id}>
              {user.name}
            </option>
          ))}
        </select>
      </div>
    </header>
  );
};

export default Header;
```

### Creating the Comment component

In our `src/components/Comment.js` component:

```javascript
import parseISO from "date-fns/parseISO";
import formatDistance from "date-fns/formatDistance";

function formatDate(dateStr) {
  const date = parseISO(dateStr);
  return formatDistance(date, new Date(), { addSuffix: true });
}

const Comment = ({ comment, isYou }) => (
  <div className="Comment">
    <div className="Comment-header">
      <div className="Comment-avatar">
        <img src={comment.author.avatar} alt={comment.author.name} />
      </div>
      <span className="Comment-author">
        {isYou ? "You" : comment.author.name}
      </span>
      <span className="Comment-time">{formatDate(comment.insertedAt)}</span>
    </div>

    <div className="Comment-body">{comment.body}</div>
  </div>
);

export default Comment;
```

Here we're using the `date-fns` library to format the date and time in which the comment was posted as a relative date time (e.g. 3-days ago). Our `formatDate` function takes care of this behavior for us using the `formatDistance` function in date-fns to generate relative dates.

You'll also notice here that we're passing in an `isYou` prop which, when `true`, shows `You` as the author name rather than the full name.

### Creating the Comments component

Next up, let's add the implementation for the `src/components/Comments.js` file. This component represents our comments sidebar and has the responsibility of showing a list of comments and displaying a form to post new comments.

The brains of our commenting application live in this component and it also houses the state about exactly which comments we have.

```javascript
import { useEffect, useState } from "react";
import Comment from "./Comment";

const Comments = ({ selectedUserId }) => {
  const [comments, setComments] = useState([]);
  const [input, setInput] = useState("");

  async function sendComment() {
    const result = await fetch("http://localhost:4000/comments", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ body: input, authorId: selectedUserId }),
    });
    const comment = await result.json();
    setComments((comments) => [...comments, comment]);
  }

  async function fetchComments() {
    const result = await fetch("http://localhost:4000/comments");
    const comments = await result.json();
    setComments(comments);
  }

  useEffect(() => {
    fetchComments();
  }, []);

  return (
    <div className="Comments">
      <h3 className="Comments-title">
        {comments.length === 1 ? `1 comment` : `${comments.length} comments`}
      </h3>

      <div className="Comments-list">
        {comments.map((comment) => (
          <Comment
            key={comment.id}
            comment={comment}
            isYou={selectedUserId === comment.authorId}
          />
        ))}
      </div>
      <div className="Comments-box">
        <form
          className=""
          onSubmit={(e) => {
            e.preventDefault();
            sendComment(input);
            setInput("");
          }}
        >
          <textarea
            value={input}
            onChange={(e) => setInput(e.target.value)}
            name="body"
            className="Comments-box__input"
          />
          <button
            type="submit"
            disabled={selectedUserId === ""}
            className="Comments-box__btn"
          >
            Send
          </button>
        </form>
      </div>
    </div>
  );
};

export default Comments;
```

There's quite a bit happening in here, so let's walk through it:

1. We setup a piece of state (`comments`) to keep track of the currently fetched comments.
2. We fetch all of the existing comments from the API by calling the `fetchComments` function on mount.
3. We have a form where we can post new comments, which calls the `sendComment` function.
4. Once a comment has been successfully sent we store it in the `comments` state.

### Updating our App component

Inside `src/App.js` we're going to set up the main component structure:

```javascript
import { useEffect, useState } from "react";
import "./App.css";
import Comments from "./components/Comments";
import Header from "./components/Header";

function App() {
  const [users, setUsers] = useState([]);
  const [selectedUserId, setSelectedUserId] = useState("");

  async function fetchUsers() {
    const result = await fetch("http://localhost:4000/users");
    const users = await result.json();

    setUsers(users);
  }

  useEffect(() => {
    fetchUsers();
  }, []);

  return (
    <div className="App">
      <Header
        users={users}
        selectedUserId={selectedUserId}
        setSelectedUserId={setSelectedUserId}
      />
      <Comments selectedUserId={selectedUserId} />
    </div>
  );
}

export default App;
```

Again, there are a few things going on here so let's break it down:

1. We keep track of fetched users in state (as `users`).
2. When the component mounts we fetch all users from the API by calling the `fetchUsers` function.
3. We also keep track of the `selectedUserId` which represents which user we've selected.
4. We pass the `selectedUserId` and `setSelectedUserId` components down to the child components.

### Styling our application

For our styling, you can replace the contents of the `src/App.css` file with:

```css
.App {
  display: flex;
  flex-direction: column;
  height: 100vh;
  width: 100vw;
  background-color: #15151c;
}

.Header {
  display: flex;
  padding: 8px 16px;
  height: 32px;
  background-color: #20222b;
  border-bottom: 1px solid #0f1014;
}

.Header-notifications {
  margin-left: auto;
  position: relative;
}

.Header-user {
  display: flex;
  align-items: center;
}

.Comments {
  width: 390px;
  margin-left: auto;
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: #20222b;
}

.Comments-title {
  color: #fff;
  margin: 16px;
  font-size: 16px;
  font-weight: normal;
}

.Comments-list {
  overflow-y: auto;
  border-top: 1px solid #0f1014;
}

.Comments-box {
  margin-top: auto;
  border-top: 1px solid #0f1014;
  padding: 16px;
}

.Comments-box__input {
  background-color: rgb(15 16 20);
  border: 1px solid transparent;
  font-family: inherit;
  font-size: 16px;
  color: #fff;
  display: block;
  box-sizing: border-box;
  width: 100%;
  height: 72px;
  padding: 8px;
  border-radius: 4px;
  margin-bottom: 8px;
}

.Comments-box__input:focus,
.Comments-box__input:active {
  border-color: rgb(91 83 255 / 75%);
  appearance: none;
}

.Comments-box__btn {
  background-color: rgb(91 83 255 / 75%);
  color: #fff;
  border: 1px solid transparent;
  padding: 4px 8px;
  display: inline-block;
  appearance: none;
  font-family: inherit;
  font-size: 14px;
  border-radius: 4px;
  margin-left: auto;
}

.Comments-box__btn:disabled {
  background-color: #ccc;
}

.Comment {
  padding: 16px;
  border-bottom: 1px solid #0f1014;
  color: #fff;
}

.Comment-header {
  margin-bottom: 4px;
  display: flex;
  align-items: center;
}

.Comment-avatar {
  width: 24px;
  height: 24px;
  background-color: #15151c;
  border-radius: 100%;
  margin-right: 8px;
  overflow: hidden;
}

.Comment-avatar img {
  width: 100%;
  height: 100%;
}

.Comment-author {
  font-weight: 600;
  font-size: 14px;
  margin-right: 8px;
}

.Comment-time {
  font-size: 14px;
  color: rgb(125 130 154);
}

.Comment-body {
  margin-left: 32px;
}
```

You should now have a functioning, styled React application that allows you to select a user in the top-left corner and post a comment as that user.

What you might notice at this stage is that our application does not post any sent comments in real-time to any other users who are looking at the application. We're going to fix this next.

## Adding Socket.io to our application for real-time communication between React and Node.js

Our goal now is to add real-time support to our client and our server so that whenever a comment is posted, everyone viewing the comments page will receive the comment. This is where [Socket.io](https://socket.io) is useful. We can add websocket support to our Node.js server, and have users connect to receive comments created in real-time.

Let's start by adding Socket.io in our webserver. Inside our `server/index.js` file we'll add the necessary socket.io setup code:

```javascript
// Add this to where your other requires are
const io = require("socket.io")(http, {
  cors: {
    origin: "http://localhost:3000",
  },
});

// Above our `app.get("/users")` handler
io.on("connection", (socket) => {
  console.log(`⚡: ${socket.id} user just connected!`);

  socket.on("disconnect", () => {
    console.log("🔥: A user disconnected");
  });
});
```

This sets up our socket.io websocket server and allows a cross-origin websocket request to be made from our client application (which is running on port `3000`). We'll also automatically log when a user connects and disconnects from the websocket, which happens when the websocket connection is terminated from the client.

The next step in our server is to **broadcast** any new comments that are created over a channel in socket.io so that we can receive them on the client. To do so, we'll change our create comment endpoint handler to emit the created comment:

```javascript
// Replace our create comment handler
app.post("/comments", async (req, res) => {
  const comment = createComment(req.body);
  io.emit("new-comment", { comment });
  res.json(comment);
});
```

Here we're saying to socket.io to `emit` (broadcast) to all connected clients on the `new-comment` channel and send a payload that includes the created comment. When we listen for this event in our client application, we'll be able to receive the created comment and add it to the list of comments that we have available in state.

And now let's implement the changes required on the client.

Inside the `client` directory let's install the `socket.io-client` library which allows us to connect from the client to the web socket connection on the server.

```bash
npm install socket.io-client
```

And in `client/src/App.js` we're going to set up the websocket connection:

```javascript
// Add with other imports
import io from "socket.io-client";

// Before the component definition:
const socket = io.connect("http://localhost:4000");

function App() {
```

Next, we're going to pass this newly created `socket` to our `Comments` component in our `src/App.js` component so we have it accessible in the `Comments` component:

```javascript
<Comments socket={socket} selectedUserId={selectedUserId} />
```

And now in `src/components/Comments.js` we'll add our real-time support for creating and receiving a comment.

Let's extract the passed-in `socket` prop in our component definition:

```javascript
const Comments = ({ socket, selectedUserId }) => {
  // Same component code
```

And now we want to connect to our `new-comment` channel when the component mounts so we'll add a new `useEffect` hook that will handle the socket setup code for us.

```javascript
useEffect(() => {
  socket.on("new-comment", ({ comment }) => {
    // Only insert comments when the current user is not the author
    if (comment.author.id !== selectedUserId) {
      setComments((comments) => [...comments, comment]);
    }
  });

  return () => {
    socket.off("new-comment");
  };
}, [setComments, socket, selectedUserId]);
```

Here we're saying that every time a `new-comment` event is received we want to _append_ that comment to the list of comments that we already have in our `comments` state. We use the function setter to do this so that any existing comments in state are preserved. Note that we're also returning a cleanup function to _stop_ listening to the channel once the component unmounts (the `return` from the `useEffect` does this).

One other important line to note here is that we're **not appending the comment to our list of comments when the selected user is the author**. This avoids the duplicate comment problem where we add the comment to the list of comments in state twice; once when it's been created in the `sendComment` handler and once when it's broadcast in the `new-comment` channel.

And with that we should now have a fully functioning real-time commenting app 🎉. You can try it out by opening up two browser windows, selecting different users, and posting a few comments. You should see the comments coming in real-time to each connected user.

## Using Knock to send cross-channel notifications

Commenting is a common use case for where you'll likely want to add some notifications to your product. In a collaboration product with commenting, you can imagine needing to add both an in-app notification feed of comments that have been left as well as an email summarizing any missed comments while the user was away from the product and did not see their notifications. This is where Knock can help.

[Knock](https://knock.app) is a developer tool for building cross-channel notification systems. With Knock, you can create complex cross-channel notifications for your product without the need to build out the system yourself. You call Knock's API and you can send notifications with a single API call to in-app and out-of-app channels like email, push, SMS, and Slack.

<img src="https://knock.app/assets/blog/building-real-time-comments-with-socket-io-and-react/knock-workflow-engine.png" alt="An example of the notification orchestration engine in Knock" />

When it comes to integrating an in-app notification feed, Knock's [pre-built React feed components](https://docs.knock.app/in-app-ui/react/overview) comes ready out of the box with real-time behavior, live updating badge counts, and the ability to mark notifications as read or seen. You can use the in-app notification components to effortlessly get in-app notification inboxes, feeds, or toasts up and running in a matter of hours, all powered by Knock's scalable and reliable notification engine.

In addition to powering in-app notifications, Knock takes care of [batching](https://docs.knock.app/designing-workflows/batch-function) (collapsing multiple notifications about a single topic into one), [intelligent orchestration](https://knock.app/features/workflow-engine) (send to email only if the in-app notification has been seen), [managing user preferences](https://knock.app/features/preferences), and gives you [unparalleled visibility](https://knock.app/features/observability) into the notifications your product is sending.

If you'd like to try it out, you can [sign up for a free account](https://dashboard.knock.app/signup) or [book a demo](/contact-sales). 👋