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.
Setup project
First of all, create a simple React project.
yarn create react-app react-chat-room
Once the project is created, make sure everything works by running the project.
cd react-chat-room
yarn start
And you'll have something similar running at http://localhost:3000.
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"
}
}
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');
}
);
After that, run the following command to make sure the server is running.
yarn start
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));
});
}
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}!`);
});
});
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
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: [],
};
The last step will be to include tailwind in the index.css
file.
/*src/index.css*/
@tailwind base;
@tailwind components;
@tailwind utilities;
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;
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;
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;
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>
);
...
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, aws
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;
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>
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>
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;
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;
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 . .
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 . .
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
Once it's done, run the containers with the following command.
docker-compose up -d --build
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.