This article is a continuation of the series on creating a real-time chat application with React Hooks, Socket.io, and Node.js. If you haven't read Part 1 yet, it's recommended to do so first to understand the context of this article.
Introduction to Rooms
We introduce a new term called rooms. A room in socket.io is a random channel that sockets can join
and leave
. It can be used to broadcast events to a subset of clients/users. But, the concept of rooms is only available on the server(i.e. the client does not have access to the list of rooms it has joined).
What we will use the room for?
We will be using the rooms for connecting multiple users to talk to each other by joining a room made from their user id or a room for a topic(say some office event, football scores, etc) where we don't want to broadcast it to everybody, just allow the subscribed people to listen to it and receive messages for that particular event.
How will the two users communicate?
In a chat application we always have user ids for every user whether there's a channel between two users(one-to-one) or 100 users, they will always have a channel id and user id of users which would always be unique. We will use unique user id to be a contender for our room name.
Firstly, we need to fetch the user id from the client side. The user can be authenticated with the token that we saw in our previous article. So, we will extract the information on the backend from the token or JWT(whichever way you prefer) and this adds a layer of security to our application so that only authenticated users can connect.
I will be using an input on the top of my page to enter a token which will be a JWT in my case. You can have the token in your app whichever way you like, and you might also have an app in production that would have already stored the token in local storage or cookies.
Authentication token as input
Let's create the input field for getting the token. Go to App.js
file and remove all the CRA boilerplate code inside className App
and paste the following code
<div className="App">
<form onSubmit={submitToken}>
<input type="text" placeholder="Enter token" ref={tokenInputRef} />
<button type="submit">Submit</button>
</form>
</div>
At the top of App.js
file
function App() {
const [token, setToken] = useState('');
const tokenInputRef = useRef('');
useEffect(() => {
if (token) {
initiateSocketConnection(token);
subscribeToChat((err, data) => {
console.log(data);
});
return () => {
disconnectSocket();
}
}
}, [token]);
}
Since I will be authenticating with the token and that token will be passed by an input, I will call my initiateSocketConnection function on submitting the token input and set the token state variable which will be a dependency in useEffect
and it gets called whenever the token changes. An if statement is added to prevent initial case of undefined token.
We are doing this [token]
instead of []
so that the backend middleware doesn't throw an error for token missing. We will handle the backend socketio middleware further which will verify the token.
In our socket.service.js
file, handle the token param in initiate function
export const initiateSocketConnection = (token) => {
socket = io(process.env.REACT_APP_SOCKET_ENDPOINT, {
auth: {
token,
},
});
console.log(`Connecting socket...`);
};
Following the previous article we will pass the token in auth and change the static 'cde'
to the token passed in by the UI input.
Token Generation
Let's generate a random token from jwt.io. I will add a id parameter here and a myRandomHash
secret to sign it. Remember that this token is only for this article explanation, in real world scenario you might already have a token in your app.
JWT website screenshot
Copy the encoded token from the left box and paste it into the input box we just made above.
Token input
Handling token authentication in NodeJS
First, to validate and parse the JWT we need to install the module *jsonwebtoken *in our node project.
npm i jsonwebtoken --save
In our Node index.js
file, modify the io middleware like this.
const jwt = require('jsonwebtoken');
// jwt secret
const JWT_SECRET = 'myRandomHash';
io.use(async (socket, next) => {
// fetch token from handshake auth sent by FE
const token = socket.handshake.auth.token;
try {
// verify jwt token and get user data
const user = await jwt.verify(token, JWT_SECRET);
console.log('user', user);
// save the user data into socket object, to be used further
socket.user = user;
next();
} catch (e) {
// if token is invalid, close connection
console.log('error', e.message);
return next(new Error(e.message));
}
});
Here our middleware is verifying for the JWT token and extracting the user data, if the token is invalid or expired we throw an *error *and don't let the code move forward. We are also storing the user info into the socket object which would make our life easy when we have to extract the user info who sent the event in future events.
Now that we have our authentication and user information ready, let's create a simple UI for the chat messages and input box.
Creating the UI
Go to App.js
file in your React project and remove the CRA boilerplate code inside className App
and paste the following code
<div className="App">
<form onSubmit={submitToken}>
<input type="text" placeholder="Enter token" ref={tokenInputRef} />
<button type="submit">Submit</button>
</form>
<div className="box">
<div className="messages"></div>
<form className="input-div" onSubmit={submitMessage}>
<input type="text" placeholder="Type in text" ref={inputRef} />
<button type="submit">Submit</button>
</form>
</div>
</div>
In App.css
file paste the following
.App {
padding: 1rem;
}
.box {
width: fit-content;
height: 400px;
border: solid 1px #000;
display: flex;
flex-direction: column;
margin-top: 1rem;
}
.messages {
flex-grow: 1;
}
.input-div {
display: flex;
width: 100%;
}
This code adds a box with a border which would be the chat box that shows the messages and an input container at the bottom for writing your message and sending it to the server.
Our page would like this with a box. Don't mind the UI, you can customize it in any way like.
Sending a message
To send a message we need to add a onSubmit on our form and fetch the current message written in the input field. Let's do the handling in App.js
file.
import {
subscribeToMessages,
initiateSocketConnection,
disconnectSocket,
sendMessage,
} from "./socketio.service";
function App() {
const CHAT_ROOM = "myRandomChatRoomId";
const [token, setToken] = useState("");
const tokenInputRef = useRef("");
const inputRef = useRef("");
useEffect(() => {
if (token) {
initiateSocketConnection(token);
subscribeToMessages((err, data) => {
console.log(data);
});
return () => {
disconnectSocket();
};
}
}, [token]);
const submitToken = (e) => {
e.preventDefault();
const tokenValue = tokenInputRef.current.value;
setToken(tokenValue);
};
const submitMessage = (e) => {
e.preventDefault();
const message = inputRef.current.value;
sendMessage({message, roomName: CHAT_ROOM}, cb => {
console.log(cb);
});
};
}
In socketio.service
file
// Handle message receive event
export const subscribeToMessages = (cb) => {
if (!socket) return(true);
socket.on('message', msg => {
console.log('Room event received!');
return cb(null, msg);
});
}
export const sendMessage = ({message, roomName}, cb) => {
if (socket) socket.emit('message', { message, roomName }, cb);
}
The function submitMessage takes the value from message input and calls a sendMessage method on the socket service which emits the message to the server along with a callback which is forwarded to the server and used for acknowledgment by the server and the last param is the CHAT_ROOM id(declared on the top as a constant since this is just a demo) which would be used on the server to identify the room. Why? Let's understand.
User wants to communicate to another user or group of users
We have 2 users, one wants to send a message to another user, and they both would be in a channel which will be a unique identifier, the same goes for group chat. So, when we send the message event to the server, the server would have to pass it on to the other users. How will the server know that the message is sent to which channel?
Purpose of CHANNEL ID
CHANNEL ID is the identifier that helps the server identify the channel. But, What will the server use it for? Now that we have the channel, the server has to send this message to each user in the channel, it can be 1 or 100.
So, to make it easy to publish all the events to those users, we will find all the participating channels of a connected user upon the io.on("connection")
event and make the user socket instance join all those channels in loop. So, this eases the process of sending the message event to that channel id/room containing the relevant users without any overhead.
Let's do that on the server side
io.on("connection", (socket) => {
// join user's own room
socket.join(socket.user.id);
console.log("a user connected");
socket.on("disconnect", () => {
console.log("user disconnected");
});
socket.on("my message", (msg) => {
console.log("message: " + msg);
io.emit("my broadcast", `server: ${msg}`);
});
socket.on("join", (roomName) => {
console.log("join: " + roomName);
socket.join(roomName);
});
socket.on("message", ({ message, roomName }) => {
console.log("message: " + message + " in " + roomName);
// send socket to all in room except sender
socket.to(roomName).emit("message", message);
callback({
status: "ok"
});
// send to all including sender
// io.to(roomName).emit("message", message);
});
});
For demo purpose as I don't have a database connected so, I will make the socket join the static channel ID we defined above in React myRandomChatRoomId
and do this under the socket.join in io.connection
socket.join('myRandomChatRoomId');
This way all the users who connect will join a common channel which would trigger events for all the users if someone sends a message.
Similarly, you can also call
socket.leave(roomName)
to remove a user from the room.
Let's try this out opening two different tabs and generating two jwt's (I changed the name and id parameter in jwt.io website to create two different users) which impersonate two different users and sending a message from one to see it being received by another. Message received So, we receive the message here on one user and not on the sender as I have already explained above the way we are sending it to all in room except the sender.
Congratulations, we have a running chat application. We just need to handle the message in the UI to append it in the box above our message input box.
Appending the incoming message
But first, we need to attach some information to the incoming message for the receiver to show names and differentiate between user ids. Let's see how can we do it.
On the backend in index.js
file
socket.on('message', ({message, roomName}, callback) => {
console.log("message: " + message + " in " + roomName);
// generate data to send to receivers
const outgoingMessage = {
name: socket.user.name,
id: socket.user.id,
message,
};
// send socket to all in room except sender
socket.to(roomName).emit("message", outgoingMessage);
callback({
status: "ok"
});
// send to all including sender
// io.to(roomName).emit("message", message);
});
We append user data keys into the message sent to receivers so that it's easy for the to append it in the UI and perform actions based on user id.
Let's append the same into the box now.
In App.js
file, let's handle the state for messages and append them, first handle the HTML
<div className="box">
<div className="messages">
{messages.map((user) => (
<div key={user.id}>
{user.name}: {user.message}
</div>
))}
</div>
<form className="input-div" onSubmit={submitMessage}>
<input type="text" placeholder="Type in text" ref={inputRef} />
<button type="submit">Submit</button>
</form>
</div>
And now the logical part above the HTML, we update the following methods to handle the incoming message and also we clear the input on the sender and append the sender's message too into the box.
// static data only for demo purposes, in real world scenario, this would be already stored on client
const SENDER = {
id: "123",
name: "John Doe",
};
useEffect(() => {
if (token) {
initiateSocketConnection(token);
subscribeToMessages((err, data) => {
console.log(data);
setMessages((prev) => [...prev, data]);
});
return () => {
disconnectSocket();
};
}
}, [token]);
const submitMessage = (e) => {
e.preventDefault();
const message = inputRef.current.value;
sendMessage({ message, roomName: CHAT_ROOM }, (cb) => {
// callback is acknowledgement from server
console.log(cb);
setMessages((prev) => [
...prev,
{
message,
...SENDER,
},
]);
// clear the input after the message is sent
inputRef.current.value = "";
});
};
There we go, we have the incoming message in the chat box and the same way the sender would have his message appended in the chat box.
BONUS: Custom topics
We can also use the rooms for custom topics that a group of users want to subscribe on, let's say football scores or product launches. Since they would be joined by a limited number of users, we can't use *broadcast *here.
We can achieve this by sending an event to the server that the client wants to join this room, or any other way the backend wants to do it since rooms can only be joined and left at the server side. For example, we can create a listener on the backend like
socket.on("join", (roomName) => {
console.log("join: " + roomName);
socket.join(roomName);
});
And emit the join event with a *roomName *from the client.
export const joinRoom = (roomName) => {
socket.emit("join", roomName);
};
And, call the joinRoom inside our App.js
with a roomName specified.
You can check all of the above written example code at my github.
This concludes my article about collaborating multiple users with the concept of Rooms and socket.io . I have covered every aspect from authentication
to rooms
and sending messages
to others. We will talk about adding a database(Firebase or MongoDB), scaling the server to multiple instances by using Socket.io Redis Adapter in the future
articles. All of this information cannot be covered in a single article.
Liked my work. Buy me a coffee.
Do write down your reviews or send in a mail from the contact form if you have any doubts and do remember to subscribe for more content like this.