Real-Time Chat Application in Javascript
Today we will build a real-time chat application is plain Javascript by using socket.io. We will implement communicating with different people and getting new notifications when the messages are coming.
The focus of this project is not to learn the basics of socket.io but on building a real application. So you need to have at least a basic understanding what are websockets and why do we need them.
Generated project
So here we have an empty server.js where we will write our backend with node and public folder. Inside public folder we have index.css and index.html. It's our frontend part of application. Inside index.html I already prepared markup for our project so we can fully focus on writing Javascript.
<!DOCTYPE html>
<html>
<head>
<title>This is the title of the webpage</title>
<script src="/socket.io/socket.io.js"></script>
<link rel="stylesheet" href="/index.css" />
</head>
<body>
<div class="welcome-screen">
<h1>Please choose your name</h1>
<div>
<input type="text" />
</div>
<div>
<button>Login</button>
</div>
</div>
<div class="chat hidden">
<div class="users-list"></div>
<div class="messages">
<div class="current-user"></div>
<div class="messages-list"></div>
<input type="text" placeholder="Type your message" class="hidden" />
</div>
</div>
<script src="/welcome.js"></script>
<script src="/chat.js"></script>
<script src="/main.js"></script>
</body>
</html>
Here we have 2 basic sections: welcome screen and chat containers. Chat is hidden by default because we need to get a user name on welcome screen before we allow access to the chat.
Also on the bottom we have 3 scripts that we didn't create yet. Welcome.js, chat.js and main.js.
And here is our prepared index.css so we won't spent time on writing styles.
.welcome-screen {
text-align: center;
}
.welcome-screen button {
margin-top: 20px;
font-size: 20px;
}
.welcome-screen input {
height: 30px;
width: 200px;
font-size: 30px;
text-align: center;
}
.chat {
display: flex;
width: 800px;
height: 500px;
margin: 0 auto;
border: 1px solid grey;
margin-top: 20px;
}
.chat .users-list {
width: 300px;
border-right: 1px solid grey;
}
.chat .users-list .active {
background: #f3f3f3;
}
.chat .users-list div {
cursor: pointer;
padding: 10px;
}
.chat .users-list div:hover {
background: #f3f3f3;
}
.chat .has-new-notification:after {
content: "❗";
display: inline-block;
}
.chat .messages {
width: 100%;
position: relative;
}
.chat .messages-list {
height: calc(100% - 60px);
}
.chat input {
width: 100%;
box-sizing: border-box;
height: 30px;
}
.chat .current-user {
border-bottom: 1px solid grey;
padding: 5px;
}
.hidden {
display: none;
}
Setting up Express server
Our first step is to setup Express server. Why Express? Actually it's not mandatory for socket.io but it's much easier to create a server with Express framework. But first of all we must install needed packages: express, nodemon and socket.io. The main question is why do we need nodemon? Every time when we change our server.js file we must restart our node process but it's not comfortable. Nodemon will restart our web server automatically. Socket.io package on the other hand is the most popular implementation of web sockets in node.js
yarn add express
yarn add nodemon
yarn add socket.io
Now we want to create our server and socket.io server. The tricky part here is that we want to combine Express with Socket.io and here is the way to do it.
// server.js
const express = require("express");
const app = express();
const http = require("http");
const server = http.createServer(app);
const { Server } = require("socket.io");
const io = new Server(server);
It might look scary but we just created here express instance, and socket.io instance. Also we provided our Express server inside socket.io. It allows us to combine Express methods and socket.io methods.
Now we want to server our public folder inside our Node application.
// server.js
app.use(express.static("public"));
app.get("/", (req, res) => {
res.sendFile(__dirname + "/public/index.html");
});
server.listen(3000, () => {
console.log("listening on 3000");
});
As you can see we used app.use to server static files through Express application and we started it on 3000 port by using server.listen. Now we need to create a new script inside package.json to run our web server.
{
...
"scripts": {
"start": "nodemon server.js"
}
}
The important part here that instead of node we wrote here nodemon which will restart our webserver automatically with every change. To start our web server we can simply write in console
yarn start
Now let's jump to http://localhost:3000 in browser. We can see our markup and our css is also loaded but obviously our scripts are not there yet because we need to create them.
So let's create this 3 files inside public folder now. welcome.js, chat.js and main.js. As you can see in browser we don't have any error in console and we see our welcome screen. It's just an input where we can type a user name and a button to logic which will submit the form.
Our first io connection
Let's jump to our server.js. As you can see on the top we have an instance of socket.io inside io variable. Now we can add our first connection.
// server.js
...
io.on('connection', socket => {
console.log('connected', socket.id)
})
If we try to reload a browser page or webserver nothing is happening. It is because we setted up a socket.io on backend but not on frontend. This is why we need to jump to public/main.js and create it there.
// public/main.js
const socket = io()
Why we have here io at all? Because inside our index.html we loaded in head tag socket.io script. It makes a io property inside window for us. And what we did inside main.js is, we created a global socket property. Now if we look in server console you can see our message connected because every time when we call io on client the web sockets connection is established.
If we check window.socket, we can see that there is an unique ID inside. It's an ID of our connection and it's extremely important as it's an identifier for our specific connected user. Even when I open a new tab we get a new socket with new unique ID. So it's unique inside a tab and not inside browser or computer.
Important: We must call io() and get socket only once on client to have a single and unique connection to our backend.
Welcome screen
Now it's time to implement our welcome screen. And I want to write the whole code by using classes. It's an easy way in plain Javascript to split and separate our logic. This is why in main.js we just want to create an instance on WelcomeScreen class.
// public/main.js
...
new WelcomeScreen()
Now we must create a WelcomeScreen class. And the first thing that we want to do inside is create references to our DOM tree as our DOM already contains all necessary elements.
// public/welcome.js
class WelcomeScreen {
constructor() {
this.$welcomeScreen = document.querySelector(".welcome-screen");
this.$loginBtn = this.$welcomeScreen.querySelector("button");
this.$input = this.$welcomeScreen.querySelector("input");
}
Now we need to add some event handlers how our WelcomeScreen. We need an event for our login button which must:
- read a value from input,
- we need to check that it is not empty
- we need to hide our welcome screen and create an instance of our Chat.
// public/welcome.js
class WelcomeScreen {
constructor() {
...
this.initializeListeners();
}
initializeListeners() {
this.$loginBtn.addEventListener("click", () => {
if (this.$input.value === "") {
return;
}
const currentUser = {
name: this.$input.value,
};
this.$welcomeScreen.classList.add("hidden");
new Chat({ currentUser });
});
}
}
So here we created a currentUser property and put a name inside because we must provide a selected name inside our Chat class. But we used here an object so it is simple to extend it later.
Now we must create our Chat class.
// public/chat.js
class Chat {}
As you can see in browser after we typed a name and hit login our page is completely empty because we hided our welcome screen. Which means everything is working as expected.
Adding a user
Now we need to notify our backend that we want to create new user. In socket.io we use for this emit command.
// public/welcome.js
...
const currentUser = {
name: this.$input.value,
};
socket.emit('user-connected', currentUser)
...
And you might ask why we write user-connected if we already have a connection event in backend. This is because we want to create a user only after we got his name. So this is an event that user successfully logged in. So the first argument of socket.emit is a name of event which is a unique string and as a second parameter we provide some additional data that we need.
Now we must create on our emit inside our backend.
// server.js
const users = {}
...
io.on('connection', socket => {
console.log('connected', socket.id)
socket.on('user-connected', user => {
users[socket.id] = {...user, id: socket.id}
console.log('user-connected', users)
})
})
Here we subscribed to our user-connected event. Also we create a users object where we will store all our active users inside our system. After user is connected we add a new unique key to our users object. And our unique key is socket.id
. As a value we write all properties that we get (in our case just name) and socket.id
.
As you can see in server console when we hit login in client we add this user to our users object.
Rendering chat
Now it's time to render our chat. First of all we must get our current user in constructor and get needed DOM events.
// public/chat.js
class Chat {
constructor({ currentUser }) {
this.currentUser = currentUser;
this.initializeChat();
}
initializeChat() {
this.$chat = document.querySelector(".chat");
this.$usersList = this.$chat.querySelector(".users-list");
this.$currentUser = this.$chat.querySelector(".current-user");
this.$textInput = this.$chat.querySelector("input");
this.$messagesList = this.$chat.querySelector(".messages-list");
this.$chat.classList.remove("hidden");
}
So we put all DOM references in initializeChat
function. Additionally we removed hidden
class from our chat. As you can see in browser we successfully rendered our chat block.
Now we need to render current user name inside our chat. It is simple to do because we just need to set innerText inside our DOM element.
// public/chat.js
initializeChat() {
...
this.$chat.classList.remove("hidden");
this.$currentUser.innerText = `Logged in as ${this.currentUser.name}`;
}
Now in browser we see our current user name in top bar after showing the chat.
Fetching users
Our next step is more complicated. We need to fetch the list of active users after we started our chat. And it is not related to web sockets at all because it is much easier to use plain REST API than something else here. Sockets can simply react on events. They won't help us if we need to just get some data without event.
This is why we must jump inside our server and create a new API call.
// server.js
...
app.get("/users", (_, res) => {
res.send(Object.values(users));
});
As you can see we returned users as an array because it's much easier to work with users in frontend as an array. Now if we open http://localhost:3000/users we see our empty array. If we login as a new user and call this API request once again, we get a user that way connected to our server.
But there is a problem here. Our users object is not updated when user closes the tab. We must cover this case because we want to see only connected users. So we need to remove a user on disconnect. We have a special disconnect event for this and it will be emmited by socket.io itself.
// server.js
io.on('connection', socket => {
...
socket.on('disconnect', () => {
delete users[socket.id]
})
})
Now when we reload our API after closing the tab it will return an empty array.
Important: As our server is restarted with every change all users connected users are deleted and after every change we must login again
Now let's create a function to fetch users on client.
// public/chat.js
class Chat {
...
async fetchUsers() {
const res = await fetch('/users')
return await res.json()
}
}
Now we need to fetch our users on initialize. This is why we must make initializeChat
asynchronous and call fetchUsers
there.
// public/chat.js
class Chat {
async initializeChat() {
...
const users = await this.fetchUsers()
console.log('users', users)
}
}
To test it properly we must have 2 tabs. We login in the first tab. When we login in second tab we will get an array with 2 users.
Rendering users
So we successfully fetched our users and now it's time to render them. For this I want to create additional function renderUsers
. Also keep in mind that we didn't store users in this.users
because we need to filter out current user from the array.
// public/chat.js
class Chat {
users = []
async initializeChat() {
...
const users = await this.fetchUsers()
this.renderUsers(users)
}
renderUsers(users) {
this.users = users.filter((user) => user.id !== socket.id);
this.$usersList.innerHTML = "";
const $users = this.users.map((user) => {
const $user = document.createElement("div");
$user.innerText = user.name;
$user.dataset.id = user.id;
return $user;
});
this.$usersList.append(...$users);
}
}
- So first of all we create an empty users array inside our class.
- Now we call
renderUsers
AFTER we got API results - Inside we want to remove our current user from array
- Clear the container
- Generate DOM elements for each user
- And append them to the container with spread
Now when we jump to the browser and login in 2 tabs you can see that in second tab we rendered a user on the left side. So it is working fine but we render users only on initialize of our application for now. This is why in the first tab we don't see any users. User 2 is logged in later and we must notify our client with web sockets about it.
This is why what we want to do is tell all our clients from the backend that we got a new login in our system.
// server.js
...
io.on('connection', socket => {
console.log('connected', socket.id)
socket.on('user-connected', user => {
users[socket.id] = {...user, id: socket.id}
console.log('user-connected', users)
socket.broadcast.emit("users-changed", Object.values(users));
})
})
So we used socket.broadcast.emit
to tell all clients that some event happened system wide. Also we used here a generic name users-changed
instead of user-connected
because we can easily reuse it. Now inside our Chat we must subscribe to this event.
// public/chat.js
constructor({ currentUser }) {
...
this.initializeListeners();
}
initializeListeners() {
socket.on("users-changed", (users) => {
this.renderUsers(users);
});
}
As you can see we created a new function initializeListeners
where inside we subscribe using socket.on
to our broadcast from the server and render our list of users. Now in browser after second user is logged in the list of users in the first tab is updated. But it's not all because we must notify all users when somebody disconnects.
// server.js
io.on('connection', socket => {
...
socket.on('disconnect', () => {
delete users[socket.id]
socket.broadcast.emit("users-changed", Object.values(users));
})
})
As you can see we do exactly the same broadcast in disconnect to notify all our clients about change in users object.
Activating chat
Now we need to implement the activation of the chat with specific contact when we click on the user. This is why we must attach a listener after rendering of every user.
// public/chat.js
class Chat {
...
renderUsers(users) {
...
this.$usersList.append(...$users);
this.initializeUsersListeners($users);
}
initializeUsersListeners($users) {
$users.forEach(($userElement) => {
$userElement.addEventListener("click", () => {
this.activateChat($userElement);
});
});
}
}
We created an initializeUsersListeners
function which loops through each DOM node and attached a click event to it. When the user is clicked we want to call our activateChat
function where we provide a DOM node inside.
It's time to implement our chat activation
// public/chat.js
class Chat {
activeChatId = null
...
activateChat($userElement) {
const userId = $userElement.dataset.id;
if (this.activeChatId) {
this.$usersList
.querySelector(`div[data-id="${this.activeChatId}"]`)
.classList.remove("active");
}
this.activeChatId = userId;
$userElement.classList.add("active");
this.$textInput.classList.remove("hidden");
}
So here we do several things:
- We create
activeChatId
property inside our class which is null by default as we didn't activate any contact - Next we get
userId
from data attribute of our DOM element - After this we remove an active class from all users to be sure that no other user is activated
- We store our userId in
this.activeChatId
- We add active class to our activated contact
- We remove a
hidden
class from text input to show a place for writing messages
Now we need to handle our typing in text input and sending messages when we hit Enter.
// public/chat.js
class Chat {
...
activateChat($userElement) {
...
this.$textInput.classList.remove("hidden");
this.$textInput.addEventListener("keyup", (e) => {
if (e.key === "Enter") {
const message = {
text: this.$textInput.value,
recipientId: this.activeChatId,
};
socket.emit("new-chat-message", message);
this.$textInput.value = "";
}
});
}
After we showed our text input we attached keup event to it where inside we check for Enter key. We use socket.emit to send a message through web sockets to our server. Inside a message we store the text of the message and a recepient so we know who needs to receive this message.
Now we need to subscribe to this emit in backend.
// server.js
io.on('connection', socket => {
...
socket.on("new-chat-message", (message) => {
console.log("new-chat-message", message);
socket.to(message.recipientId).emit("new-chat-message", {
text: message.text,
senderId: socket.id,
});
});
})
So we already used socket.on
to subscribe we events. But here we used socket.to
to send a message from backend to a specific client. It is really nice as we don't want not broadcast event to all clients. As you see we provide for our client a text and a sender which is the socket.id
of the user which sends us a message.
Now on client we can add a listener for this event which backend sends us.
javascript
// public/chat.js
class Chat {
...
initializeListeners() {
socket.on("new-chat-message", (message) => {
console.log('new-chat-message', message)
});
}
}
As you can see in browser our specific user gets a message from the first tab to the second tab via web sockets and our server.
Rendering messages
Now before we proceed with rendering a new chat message we must implement the function to render the list of our messages. We won't store any messages on server but only client side in memory of browser. This is why I want to create messages
as an object inside our Chat
. Inside we will store messages of each user as a key and array of them as a value.
First of all we need to create a new function addMessage which we will call on Enter.
// public/chat.js
class Chat {
messages = {}
...
activateChat($userElement) {
...
this.$textInput.addEventListener("keyup", (e) => {
...
socket.emit("new-chat-message", message);
this.addMessage(message.text, message.recipientId)
this.$textInput.value = "";
}
);
}
addMessage(text, userId) {
if (!this.messages[userId]) {
this.messages[userId] = [];
}
this.messages[userId].push(text);
}
}
It's important to check if we have a user key before we try to push a new message inside our object. In other case we will get an error. After we pushed our new message we must render the list of messages.
// public/chat.js
class Chat {
messages = {}
...
activateChat($userElement) {
...
this.$textInput.addEventListener("keyup", (e) => {
...
socket.emit("new-chat-message", message);
this.addMessage(message.text, message.recipientId)
this.renderMessages(message.recipientId)
this.$textInput.value = "";
}
);
}
...
renderMessages(userId) {
this.$messagesList.innerHTML = "";
if (!this.messages[userId]) {
this.messages[userId] = [];
}
const $messages = this.messages[userId].map((message) => {
const $message = document.createElement("div");
$message.innerText = message;
return $message;
});
this.$messagesList.append(...$messages);
}}
As you can see we provide just a userId because we read data from our messages object. Most importantly we check if this key exists and if now we write inside an empty array.
If we check our browser now we can type messages in our input and see them directly on the top. But the problem is here that we call renderMessages
only when we submit a new message. And it is not enough because we also want to render them when we switch between users.
// public/chat.js
class Chat {
...
activateChat($userElement) {
...
this.$textInput.classList.remove('hidden')
this.renderMessages(userId)
}
Now the list of messages is updated when we jump between our contacts.
Showing notifications
Just to remind you we have a subscription for a new notification on the top but we didn't write any logic inside. We need to cover 2 cases there. First is when we get a message from our active chat and second is we got message from somebody else are we must show a notification symbol.
// public/chat.js
socket.on("new-chat-message", (message) => {
this.addMessage(message.text, message.senderId);
if (message.senderId === this.activeChatId) {
this.renderMessages(message.senderId);
} else {
this.showNewMessageNotification(message.senderId);
}
});
So first of all we call addMessage to update our this.messages
. We also check if our sender is active chat and just render messages again. If not we call a showNewMessageNotification
function which we need to implement.
// public/chat.js
showNewMessageNotification(senderId) {
this.$usersList
.querySelector(`div[data-id="${senderId}"]`)
.classList.add("has-new-notification");
}
So this function just adds a class has-new-notification
to the element with our senderId. And it is all fine but we should also add removing of the notification when we open the contact chat.
// public/chat.js
activateChat($userElement) {
const userId = $userElement.dataset.id;
...
this.$usersList
.querySelector(`div[data-id="${userId}"]`)
.classList.remove("has-new-notification");
}
So we simple remove notification class every time when we activate chat.
As you can see in browser our project is fully implemented and all features are working correctly.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!