WebSocket with React, Nodejs, and Docker: Building a Chat Application

Mangabo Kolawole - Apr 24 '22 - - Dev Community

Websockets is a great technology if you are looking to build reactive or event-driven applications. Most of the time, this is the same technology used by instantaneous messaging products.

In this article, we'll build a chat application using React and Node. At the end of this article, there is an optional part ( but very useful ) on how to wrap the whole project into Docker.🚀

Demo Project

Here's a demo of what we'll be building.

Demo

Setup project

First of all, create a simple React project.

yarn create react-app react-chat-room
Enter fullscreen mode Exit fullscreen mode

Once the project is created, make sure everything works by running the project.

cd react-chat-room
yarn start
Enter fullscreen mode Exit fullscreen mode

And you'll have something similar running at http://localhost:3000.

Started React application

After that, let's set up the Node server. Inside the project root, create a directory called server.

Inside this directory, create an index.js file and a package.json file too.

Here's the content of the package.json file.

{
    "private": true,
    "name": "websocket-chat-room-server",
    "description": "A React chat room application, powered by WebSocket",
    "version": "1.0.0",
    "main": "index.js",
    "scripts": {
        "start": "node ."
    },
    "dependencies": {
        "ws": "^8.5.0"
    }
}
Enter fullscreen mode Exit fullscreen mode

And inside the index.js file, add this basic configuration. We are just starting the ws server to make sure everything is working.

const WebSocket = require('ws');

const server = new WebSocket.Server({
        port: 8080
    },
    () => {
        console.log('Server started on port 8080');
    }
);
Enter fullscreen mode Exit fullscreen mode

After that, run the following command to make sure the server is running.

yarn start
Enter fullscreen mode Exit fullscreen mode

Writing the chat feature on the server-side

The Node server handles all requests sent via WebSockets. Let's build a simple backend feature to notify all chat users about messages.
Here's how it'll go:

  • The user opens a connection and joins a room.
  • Once he has joined the room, he can send a message.
  • The message is received by the server and passes some validation checks.
  • Once the message is validated, the server notifies all users in the chat room about the message.

First of all, let's create a set of users and also a function to send a message.

...
const users = new Set();

function sendMessage (message) {
    users.forEach((user) => {
        user.ws.send(JSON.stringify(message));
    });
}
Enter fullscreen mode Exit fullscreen mode

With these basics function ready, let's write the basics interactions ws methods to handle message events, connection events, and close events.

server.on('connection', (ws) => {
    const userRef = {
        ws,
    };
    users.add(userRef);

    ws.on('message', (message) => {
        console.log(message);
        try {

            // Parsing the message
            const data = JSON.parse(message);

            // Checking if the message is a valid one

            if (
                typeof data.sender !== 'string' ||
                typeof data.body !== 'string'
            ) {
                console.error('Invalid message');
                return;
            }

            // Sending the message

            const messageToSend = {
                sender: data.sender,
                body: data.body,
                sentAt: Date.now()
            }

            sendMessage(messageToSend);

        } catch (e) {
            console.error('Error passing message!', e)
        }
    });

    ws.on('close', (code, reason) => {
        users.delete(userRef);
        console.log(`Connection closed: ${code} ${reason}!`);
    });
});
Enter fullscreen mode Exit fullscreen mode

Well, the WebSocket server is working. We can now move the UI of the chat application with React.

Writing the chat application with React

The React application will have the following workflow:

  • The user is redirected by default to a page where he enters a username.
  • After entering the username, the user is redirected to the chat room and can start talking with other online members.

Let's start by installing the needed packages such as react-router for routing in the application and tailwind for styling.

yarn add react-router-dom tailwindcss
Enter fullscreen mode Exit fullscreen mode

Next, we need to create a configuration file for tailwind.
Use npx tailwindcss-cli@latest init to generate tailwind.config.js file containing the minimal configuration for tailwind.

module.exports = {
  purge: ["./src/**/*.{js,jsx,ts,tsx}", "./public/index.html"],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {},
  },
  variants: {
    extend: {},
  },
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

The last step will be to include tailwind in the index.css file.

/*src/index.css*/

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

After that, create the src/components directory and add a new file named Layout.jsx. This file will contain a basic layout for the application so we can avoid DRY.

import React from "react";

function Layout({ children }) {
  return (
    <div className="w-full h-screen flex flex-col justify-center items-center space-y-6">
      <h2 className="text-3xl font-bold">React Ws Chat</h2>
      {children}
    </div>
  );
}

export default Layout;
Enter fullscreen mode Exit fullscreen mode

In the same directory, create a file called SendIcon.js and add the following content.

const sendIcon = (
  <svg
    width="20"
    height="20"
    viewBox="0 0 20 20"
    fill="none"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path
      d="M19 10L1 1L5 10L1 19L19 10Z"
      stroke="black"
      strokeWidth="2"
      strokeLinejoin="round"
    />
  </svg>
);

export default sendIcon;

Enter fullscreen mode Exit fullscreen mode

Writing the authentication page

Inside the src/pages, create a new file called LoginPage.jsx. Once it's done, let's add the JavaScript logic to handle the form submission.

import React from "react";
import { useNavigate } from "react-router-dom";
import Layout from "../components/Layout";

function LoginPage() {

  const navigate = useNavigate();

  const [username, setUsername] = React.useState("");

  function handleSubmit () {
    if (username) {
        navigate(`/chat/${username}`);
    }
  }

  return (
      <Layout>
      // Form here
      </Layout>
  )
}

export default LoginPage;
Enter fullscreen mode Exit fullscreen mode

And finally here's the JSX.

...
  return (
    <Layout>
      <form class="w-full max-w-sm flex flex-col space-y-6">
        <div class="flex flex-col items-center mb-6 space-y-6">
          <label
            class="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
            for="username"
          >
            Type the username you'll use in the chat
          </label>
          <input
            class="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500"
            id="username"
            type="text"
            placeholder="Your name or nickname"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
          />
        </div>
        <div class="md:flex md:items-center">
          <div class="md:w-1/3"></div>
          <div class="md:w-2/3">
            <button
              class="self-center shadow bg-purple-500 hover:bg-purple-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded"
              type="button"
              onClick={handleSubmit}
            >
              Log in the chat
            </button>
          </div>
        </div>
      </form>
    </Layout>
  );
  ...
Enter fullscreen mode Exit fullscreen mode

Let's explain what we are doing here:

  • We are defining the state and functions needed to submit the form and move to the chat room.

  • We also make sure that the username value is not empty.

Nice, let's move to the next step, the hottest part of this project.

Writing the Chat room component

Inside the src/pages, create a file called ChatPage.jsx. This file will contain all the logic and UI for the Chat room feature.
Before going into coding it, let's talk about how the WebSocket connection is handled here.

  • Once the user is redirected to the ChatPage.jsx page, a ws connection is initiated.
  • If the user enters and sends a message, an event of type message is sent to the server.
  • Every time another user is sending a message, an event is sent to the React application and we update the list of messages shown on the screen.

Let's write the js logic to handle this first.

import React, { useRef } from "react";
import Layout from "../components/Layout";
import { useParams } from "react-router-dom";
import { sendIcon } from "../components/SendIcon"

function ChatPage() {
  const [messages, setMessages] = React.useState([]);
  const [isConnectionOpen, setConnectionOpen] = React.useState(false);
  const [messageBody, setMessageBody] = React.useState("");

  const { username } = useParams();

  const ws = useRef();

  // sending message function

  const sendMessage = () => {
    if (messageBody) {
      ws.current.send(
        JSON.stringify({
          sender: username,
          body: messageBody,
        })
      );
      setMessageBody("");
    }
  };

  React.useEffect(() => {
    ws.current = new WebSocket("ws://localhost:8080");

    // Opening the ws connection

    ws.current.onopen = () => {
      console.log("Connection opened");
      setConnectionOpen(true);
    };

    // Listening on ws new added messages

    ws.current.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setMessages((_messages) => [..._messages, data]);
    };

    return () => {
      console.log("Cleaning up...");
      ws.current.close();
    };
  }, []);

  const scrollTarget = useRef(null);

  React.useEffect(() => {
    if (scrollTarget.current) {
      scrollTarget.current.scrollIntoView({ behavior: "smooth" });
    }
  }, [messages.length]);

  return (
    <Layout>
      // Code going here
    </Layout>
  );
}

export default ChatPage;
Enter fullscreen mode Exit fullscreen mode

Let's add the UI for the list of messages first.

...
      <div id="chat-view-container" className="flex flex-col w-1/3">
        {messages.map((message, index) => (
          <div key={index} className={`my-3 rounded py-3 w-1/3 text-white ${
            message.sender === username ? "self-end bg-purple-600" : "bg-blue-600"
          }`}>
            <div className="flex items-center">
              <div className="ml-2">
                <div className="flex flex-row">
                  <div className="text-sm font-medium leading-5 text-gray-900">
                    {message.sender} at
                  </div>
                  <div className="ml-1">
                    <div className="text-sm font-bold leading-5 text-gray-900">
                      {new Date(message.sentAt).toLocaleTimeString(undefined, {
                        timeStyle: "short",
                      })}{" "}
                    </div>
                  </div>
                </div>
                <div className="mt-1 text-sm font-semibold leading-5">
                  {message.body}
                </div>
              </div>
            </div>
          </div>
        ))}
        <div ref={scrollTarget} />
      </div>
Enter fullscreen mode Exit fullscreen mode

The messages from the user will be in purple and the messages from other users will be in blue.

Next step, let's add a small input to enter a message and send it.

...
      <footer className="w-1/3">
        <p>
          You are chatting as <span className="font-bold">{username}</span>
        </p>
        <div className="flex flex-row">
          <input
            id="message"
            type="text"
            className="w-full border-2 border-gray-200 focus:outline-none rounded-md p-2 hover:border-purple-400"
            placeholder="Type your message here..."
            value={messageBody}
            onChange={(e) => setMessageBody(e.target.value)}
            required
          />
          <button
            aria-label="Send"
            onClick={sendMessage}
            className="m-3"
            disabled={!isConnectionOpen}
          >
            {sendIcon}
          </button>
        </div>
      </footer>
Enter fullscreen mode Exit fullscreen mode

Here's the final code for the ChatPage component.

import React, { useRef } from "react";
import Layout from "../components/Layout";
import { useParams } from "react-router-dom";
import { sendIcon } from "../components/SendIcon"

function ChatPage() {
  const [messages, setMessages] = React.useState([]);
  const [isConnectionOpen, setConnectionOpen] = React.useState(false);
  const [messageBody, setMessageBody] = React.useState("");

  const { username } = useParams();

  const ws = useRef();

  // sending message function

  const sendMessage = () => {
    if (messageBody) {
      ws.current.send(
        JSON.stringify({
          sender: username,
          body: messageBody,
        })
      );
      setMessageBody("");
    }
  };

  React.useEffect(() => {
    ws.current = new WebSocket("ws://localhost:8080");

    ws.current.onopen = () => {
      console.log("Connection opened");
      setConnectionOpen(true);
    };

    ws.current.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setMessages((_messages) => [..._messages, data]);
    };

    return () => {
      console.log("Cleaning up...");
      ws.current.close();
    };
  }, []);

  const scrollTarget = useRef(null);

  React.useEffect(() => {
    if (scrollTarget.current) {
      scrollTarget.current.scrollIntoView({ behavior: "smooth" });
    }
  }, [messages.length]);

  return (
    <Layout>
      <div id="chat-view-container" className="flex flex-col w-1/3">
        {messages.map((message, index) => (
          <div key={index} className={`my-3 rounded py-3 w-1/3 text-white ${
            message.sender === username ? "self-end bg-purple-600" : "bg-blue-600"
          }`}>
            <div className="flex items-center">
              <div className="ml-2">
                <div className="flex flex-row">
                  <div className="text-sm font-medium leading-5 text-gray-900">
                    {message.sender} at
                  </div>
                  <div className="ml-1">
                    <div className="text-sm font-bold leading-5 text-gray-900">
                      {new Date(message.sentAt).toLocaleTimeString(undefined, {
                        timeStyle: "short",
                      })}{" "}
                    </div>
                  </div>
                </div>
                <div className="mt-1 text-sm font-semibold leading-5">
                  {message.body}
                </div>
              </div>
            </div>
          </div>
        ))}
        <div ref={scrollTarget} />
      </div>
      <footer className="w-1/3">
        <p>
          You are chatting as <span className="font-bold">{username}</span>
        </p>

        <div className="flex flex-row">
          <input
            id="message"
            type="text"
            className="w-full border-2 border-gray-200 focus:outline-none rounded-md p-2 hover:border-purple-400"
            placeholder="Type your message here..."
            value={messageBody}
            onChange={(e) => setMessageBody(e.target.value)}
            required
          />
          <button
            aria-label="Send"
            onClick={sendMessage}
            className="m-3"
            disabled={!isConnectionOpen}
          >
            {sendIcon}
          </button>
        </div>
      </footer>
    </Layout>
  );
}

export default ChatPage;
Enter fullscreen mode Exit fullscreen mode

Great! Let's move to register the routes.

Adding routes

Inside the App.js file, add the following content.

import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { LoginPage, ChatPage } from "./pages";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<LoginPage />} />
        <Route path="/chat/:username" element={<ChatPage />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

After that make sure your application is running and you can start testing.

Dockerizing the application

It's great to have many servers running in this project but it requires quite a lot of setup. What if you are looking to deploy it for example? It can be quite complicated.

Docker is an open platform for developing, shipping, and running applications inside containers.
Why use Docker?
It helps you separate your applications from your infrastructure and helps in delivering code faster.

If it's your first time working with Docker, I highly recommend you go through a quick tutorial and read some documentation about it.

Here are some great resources that helped me:

Firstly, add a Dockerfile at the root of the project. This Dockerfile will handle the React server.

FROM node:16-alpine

WORKDIR /app

COPY package.json ./

COPY yarn.lock ./

RUN yarn install --frozen-lockfile

COPY . .
Enter fullscreen mode Exit fullscreen mode

After that, add also a Dockerfile in the server directory.

FROM node:16-alpine

WORKDIR /app/server

COPY package.json ./server

COPY yarn.lock ./server

RUN yarn install --frozen-lockfile

COPY . .
Enter fullscreen mode Exit fullscreen mode

And finally, at the root of the project, add a docker-compose.yaml file.

version: "3.8"
services:
  ws:
    container_name: ws_server
    restart: on-failure
    build:
      context: .
      dockerfile: server/Dockerfile
    volumes:
      - ./server:/app/server
    ports:
      - "8080:8080"
    command: >
      sh -c "node ."

  react-app:
    container_name: react_app
    restart: on-failure
    build: .
    volumes:
      - ./src:/app/src
    ports:
      - "3000:3000"
    command: >
      sh -c "yarn start"
    depends_on:
      - ws
Enter fullscreen mode Exit fullscreen mode

Once it's done, run the containers with the following command.

docker-compose up -d --build
Enter fullscreen mode Exit fullscreen mode

The application will be running at the usual port.

And voilà! We've successfully dockerized our chat application.🚀

Conclusion

In this article, we've learned how to build a chat application using React, Node, and Docker.

And as every article can be made better so your suggestion or questions are welcome in the comment section. 😉

Check the code of this tutorial here.

Article posted using bloggu.io. Try it for free.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .