In Part 1 of this short series, we had an in-depth look at the way WebSockets work under the hood, throughout the lifecycle of a WebSocket connection. Now, we'll use React and Node.js to build a simple chat room application, with just a few bells and whistles that fully make use of the features offered by the WebSocket protocol.

Prerequisites

This tutorial is for most, but not for all β€” but chances are, if you clicked on this article, you meet all the requirements. To build this app, you'll need

  • good knowledge of HTML and JS,
  • some experience with React and Node.js, and
  • a solid understanding of the WebSocket connection lifecycle (we covered this in Part 1).

In addition to these, bonus points if you have experience with a toolchain like Webpack β€” we'll use Vite for this tutorial, but I've already taken care of setting it up.

First things first: Scaffolding the app

For the sake of brevity, I'll avoid describing all the boring setup steps needed to get our project up and running: we'll start from a boilerplate, which you can download by running

npx degit https://github.com/DamianoMagrini/websocket-chat-room-boilerplate.git websocket-chat-room

This creates a websocket-chat-room folder with two subdirectories:

  • client, where I've set up a React app with the Vite build tool and hooked up some pages with React Router, and
  • server, which contains a very simple Node.js app with the ws dependency installed and a main.js file that imports it.

By building onto these two boilerplates, we'll have the opportunity to practice WebSocket both on the client and the server side. Let's begin from the latter! Before starting, however, you should run npm install (or yarn) in both the client and server directories.

Implementing the server

What we'll be building

Before stepping into the actual code, let's outline our course of action:

  1. Firstly, we'll start a server that listens for HTTP/1.1 connections and is able to Upgrade them to WebSockets β€” while this may sound complicated, it is actually a one-liner thanks to the ws library.
  2. Then, every time a new WebSocket connection is initiated, the server should store a reference to the user's socket and add it to a Set of open sockets (which correspond to presently connected users).
  3. Whenever the server receives a message from one of the sockets/users, it should it them to every socket/user.
  4. Finally, when a socket is closed, the server should remove its reference from the Set (not doing so would lead to a memory leak).

The code

We can now dive right into the code! After running npm install (or yarn) in the server directory, let's open lib/main.js, where, as you'll see, the ws package has already been required. Here, we can start a WebSocket server by typing:

This line of code will start a WebSocket server that accepts connections on port 8081 (8080 would also be a reasonable choice, but that happens to be the default port for our client app). If we wanted to be notified when the server starts, we could also include a callback, like this:

At this point, our server is relatively useless: it won't do anything with the connections it receives besides handling the opening and closing handshakes. Let's fix this by listening for new WebSocket connections β€” and let's also initialize the Set of users:

The code within server.on will run every time a new WebSocket connection is initialized. This code currently does two things:

  • it attaches two (currently empty) event listeners to each new socket, and
  • it creates a userRef object and adds it to the users set (we'll use it to store some extra data about each user β€” but for now, it just contains a reference to the socket).

As for the close listener, that's quite straightforward: when a user disconnects, we'll remove their userRef from the set. Our code, thus, becomes:

If you were wondering why I used a Set instead of an Array, this snippet may have made it clearer: Sets allow you to delete by value (so we can simply pass userRef to the delete method), whereas Arrays require you to provide an index (using an Array, we would have needed to write users.splice(users.indexOf(userRef)), much wordier and less performant).

So, at this point, all that is left to do is implement the logic to receive and forward messages. Let's start by writing a basic sendMessage function, which iterates over each user and sends them a message. (Remember that WebSockets can only transmit text or binary data, so JS objects will need to be stringified. You may ask, "Aren't messages already strings?" Yes and no β€” for our purposes, each message will have three properties: the name of the user who sent it, the message's body, and the time it was sent at.)

Now, let's code the logic that takes care of handling the messages sent by each user. But first, let us break down what we want to happen:

  1. The user sends a JSON message with two properties: sender (the user's name) and body (the message's body). Both must be strings, or the message is invalid.
  2. The server ensures that the message it just received is valid JSON and attempts to parse it. If it isn't valid JSON, the server catches the error thrown by JSON.parse and logs an error to the console.
  3. The server also makes sure that both the sender and body properties are strings. If they aren't, it logs an error to the console and halts (it doesn't forward the message to the chat room).
  4. Finally, if all the other steps succeeded, the server sends the message to all users (using the sendMessage function we defined above), setting its sentAt to Date.now() (the timestamp at which the message was sent).

The corresponding code will look like this:

Extra features: Kicking inactive users and flooders

Let's start by creating a simple system to kick inactive users, i.e., those who haven't sent a message during the last 5 minutes (300 seconds, or 300,000 ms). We'll add a lastActiveAt property to the userRef object and update it whenever the user successfully sends a message (at the end of the try block).

Even though, admittedly, this is not a very good practice, we'll use a global setInterval to periodically kick inactive users (every 10 seconds). How? It's as simple as closing the connection!

Note that we also specified

  • a close code (this is arbitrary: for consistency, every time we kick a user β€” whatever the reason β€” we'll use a code of 4000; in general, this code can range from 4000 to 4999, while all other values are reserved) and
  • a reason for closing the connection ("inactivity"),

both of which will come in useful later.

Now, time to kick users who flood the chat! πŸ‘’ The logic for this is quite simple: users are allowed to send at most 30 messages per minute and will get kicked for flooding the chat if the amount of messages they sent in the last 60 seconds exceeds this limit. We'll implement this in three steps:

1. We first create a global recentMessages array, in which we'll be storing all the messages that were sent in the last 60 seconds.

2. Every time a user successfully sends a message (again, at the end of the try block), we add it to the array and start a timeout that, after 60 seconds, will remove the message from the array.

Lastly, we add another check before calling sendMessage: that the number of recentMessages sent by the user (i.e., those whose sender property is equal to the one provided by the user) be not yet β‰₯30. If it is, we just kick the user, again providing

  • the close code 4000 (same as before, to indicate that the user was kicked) and
  • the reason "flooding the chat".

And that's all for our backend! To recap, we built a server that

  • handles WebSocket handshakes (converting HTTP/1.1 connections to WebSocket connections),
  • stores a record of all the users currently connected,
  • listens for messages from each user and forwards them to everyone, and
  • kicks inactive users and those who have been flooding the chat.

This is the final code we got to:

We can now start working on the client.

Implementing the client

Like we did before, let's start with an overview what we'll build:

  • an authentication page, where users can enter their names;
  • a chat page, which allows users to send and receive messages;
  • a "kicked" page, which notifies users that they were kicked from the chat and gives some additional info as to why that happened (it'll leverage the close code and reason from earlier).

I have already taken care of writing the pages' CSS and hooking them up to each other with React Router, so we'll skip that part.

The auth page

None
What we'll be building

Let's open the AuthPage.jsx file in client/src/pages. As you can see, a part of the page has already been written (the useNavigate hook provides a navigate function that can be used to move between routes in React Router). Inside the <main> element, let's scaffold the page's static contents:

You'll notice that I have used some odd class names here and there: they are just for styling the app and making it look a bit nicer β€” feel free to peek inside src/index.scss if you want to tweak the styles or are just curious!

Now, let's create a name state using the useState:

Then, let's bind its value to the <input> element by adding a couple of props:

Finally, let's create an onSubmit function that (as long as the name isn't empty) will route the user to the chat room page...

…and let's run this function when the button is clicked or the user presses the ↡ Enter key.

To recap, here is the finished code for this page:

The chat page

None
What we'll be building

This is the meat of our application, where we open, interact with, and close the WebSocket connection. If you open src/pages/ChatPage.jsx, you may notice that I have included a sendIcon element, which we'll use in a moment. But first of all, let's scaffold the page by creating a header, a chat container, and a container for the message input field:

While this might look like a lot, all we've done was creating the static elements: the header, the chat container, and the elements that will allow users to type and send messages.

Displaying messages on screen

Before sending and receiving messages, let's write some logic to display them on screen: we'll create a messages state (an array of messages, each one with sender, body, and sentAt properties), then map each message to an element inside our chat-view-container.

However, if the message was sent by the current user (i.e., if message.sender === name), it should be displayed a little differently. In particular,

  1. the message container should have a different background and be flushed right instead of left (the .own-message class takes care of this), and
  2. instead of showing the sender's name, it should show "You".

Let's implement these changes:

Receiving messages

Time for WebSockets! Let's set up a WebSocket connection when the ChatPage component is mounted β€” we'll use a useEffect hook to initiate the connection, and a useRef hook to keep a reference to it.

As you can see, this code will connect to port 8081 of localhost (where, as you may remember, our server lives), but it doesn't do anything else. Let's add two listeners:

  • an onmessage listener, where we'll append new messages to the messages array;
  • an onclose listener, where we'll detect if the user was kicked (in that case, the close code will be 4000) and, if so, redirect them to the "kicked" page, including in React Router's state the reason for being kicked.

Here is what it looks like in code:

Notice that we didn't include try/catch blocks, because we can trust that the server will only send valid JSON. Finally, let's add some cleanup code to close the connection when the user navigates away from the page:

Since we already hooked up the messages array to what the user will see on screen, we've got no more work to do there.

Sending messages

We have already set up message-input-container, so now we need to make it interactive. Let's create a messageBody state and bind its value to the <input>:

Now, let's write a send function that

  1. ensures that the message body is not empty,
  2. sends the message, and
  3. clears the input contents.

We'll connect it both to the <input> and the <button>, just like we did in the auth page.

Let's add a couple of finishing touches.

Disabling the button until the connection is open

If the user is on a particularly slow network, the WebSocket connection may take some extra time to open β€” to make sure no messages are sent before the connection is open, we can disable the button. This is quite simple to do: we will

  1. create an isConnectionOpen state defaulting to false,
  2. disable the button if isConnectionOpen is false, and
  3. set the state to true as soon as the connection is opened.

In code,

Scrolling to the last message

This is the last step for this page, I promise. Whenever a new message is received and added to the messages array (which is equivalent to saying, "whenever the length of the messages array changes"), we'll scroll to the bottom of the chat container, revealing the new message. This is very easy to do: we just need to add a placeholder <div> at the very bottom of the chat container and scroll it into view whenever messages.length changes (we'll do this with a useEffect hook).

That's it for the chat page β€” phew! πŸ˜… Let's review the whole code we wrote:

Just one last effort! Let's build the "kicked" page.

The "kicked" page

None
What we'll be building

This page will be structurally quite similar to the auth page, in that both have a heading, a subhead, and a button to go back to the home page (no input here β€” much simpler!). You might remember that we passed the reason for being kicked as the state for this route: in line 6 (const { state } = useLocation()) we retrieve this state. Inside the <main> element, let's mimic the structure of the auth page:

Done! πŸ‘Œ Here's the whole code, for convenience:

Putting it all together

We can now open two terminal windows, one in the server and one in the client directory. In the server directory, we can run either npm start or yarn start to start the server; in the other one, npm run dev/yarn dev will start the development server and open the app. Play around with it! Open multiple windows, enter silly usernames, wait to be kicked or flood the chat β€” the world is your oyster. Once you feel ready to build the app, you can do so by running npm run build/yarn build in the client directory (the server can be run as-is).

To recap: things we learned and applied

To consolidate our knowledge, let's review the concepts we have learned in this two-part series:

  • We examined the inner workings and lifecycle of the WebSocket protocol.
  • Using Node.js and ws, we set up a WebSocket server that forwards each user's messages to all users, kicking inactive users and those who flood the chat.
  • With React and the native WebSocket API, we coded a WebSocket client for our application, which handles message exchange and kicking.

Do you feel you master all these concepts? If so, I wholeheartedly encourage you to practice and build something yourself to practice. And please let me know if something was unclear β€” I'd love to get in touch and help!