Build a Real-time Notification System with Socket.IO and ReactJS
Emil Pearce
Posted on October 2, 2024
Building real-time notifications is complex. You need a solid infrastructure that supports real-time data delivery, ensuring instant and reliable notifications, even with high user activity. This requires scalable backend systems and efficient communication protocols like WebSockets or push notifications to maintain low latency. For admins in chat rooms or similar environments, timely monitoring of room activities requires setting up an email notification system that alerts them about key events or issues in chat rooms, which adds to the overall challenge.
This tutorial will guide you through building a real-time notification system for a chat app using React and Socket.io. React is ideal for creating dynamic, reusable components and efficiently managing your app’s state. Socket.io enables real-time, bidirectional communication between the server and the browser, allowing instant message delivery without page reloads. This combination lets you build a responsive, interactive UI that communicates with the server in real-time.
We’ll deliver real-time email notifications with Novu.
Prerequisites
Before we dive into the tutorial, make sure you have the following:
- Node.js - installed on the computer, allows us to run JavaScript code outside a web browser.
- Socket.io - for seamless communication between the React frontend and the server
With these prerequisites in place, we're ready to start building the real-time application. Want to see the complete code? Check it out on GitHub. Now, let’s proceed with setting up the development environment.
Setting up the development environment
First, open up the terminal, navigate to a desired directory, and run the following commands:
mkdir realtime-chat-app
cd realtime-chat-app
npm init --y
The commands above create a new directory called “realtime-chat-app.” Once that’s set up, it’s time to initialize the project. Inside the directory, run npm init
to start a new npm project. The —y
flags select the default values of all the prompts.
Installing dependencies
Now that the project is ready, it’s time to install the necessary dependencies. Still in the "realtime-chat-app" directory, run the command below to install all the dependencies required to set up the backend server.
npm install Express socket.io @novu/node cors nodemon
The above command will install the following npm packages:
- Express - a flexible foundation for building our Node.js backend server.
- socket.io - for seamless communication between the React frontend and the server.
- cors - to enable secure communication by managing access control for cross-origin requests.
- nodemon - automatically restarts our Node.js server whenever we make changes to the codebase
With the required dependencies installed, it’s time to create the index.js
file. As specified in package.json
, this file will serve as the entry point for the backend server. From here, the core of the backend starts coming together.
Building the backend server with Express
To initialize the backend server, import the required modules into the index.js file and instantiate an Express application with the following code:
const Express = require("Express");
const http = require("http");
const cors = require("cors");
const app = Express();
app.use(cors());
const PORT = 3001;
server.listen(PORT, () => {
log(`server running on ${PORT}`);
});
Here, an Express application instance is initialized using Express()
, and the cors()
middleware is applied to the app to allow requests from different origins. The PORT
constant specifies the server's listening port.
To initiate the Express server, modify the package.json
script with the following code:
"start": "nodemon index.js"
Running npm start
launches the server at http://localhost:3001
. With that, the Express setup is completed; now, let's integrate Socket.IO into the server.
Integrating Socket.IO into the server
First, import Socket.IO and Novu modules inside the index.js
file with the following lines of code:
#index.js
const { server } = require("socket.io");
const { Novu } = require("@novu/node");
The next thing is to create a Socket.IO connection. Update the index.js
with the following snippet:
#index.js
const io = new server(server, {
cors: {
origin: "http://localhost:3000",
methods: ["GET", "POST"],
},
});
The snippet above creates a new instance of the Socket.IO server and specifies the origin of the connections that are allowed. In this case, it's http://localhost:3000
because that is the port the React.js
framework will run on. This code also defines the allowed HTTP methods for requests. Now it's time to create some events.
Handling Socket.IO events on the server
The first event to set up is the "connection" event, which fires whenever a user visits the application. This event helps us track when users are connected to the server. Let’s add it to the index.js
file along with some additional events to manage user actions:
#index.js
io.on("connection", (socket) => {
console.log(`User connected ${socket.id}`);
// Event for when a user joins a room
socket.on("join_room", (data) => {
socket.join(data.room);
console.log(data);
});
// Event for when a user sends a message
socket.on("send_message", (data) => {
console.log(data.message);
});
// Event for when a user disconnects
socket.on("disconnect", () =>console.log("User disconnected", socket.id));
});
The "join_room" event fires when a user has joined a room (like a chat group). The "send_message" event will fire when a user sends a message, while the "disconnect" event runs when the user leaves or refreshes the application.
The data passed through the "join_room" and "send_message" events will come from the React application. Now that we have created the server-side event handlers, let's create the React application and see how it will interact with these events and communicate with the Socket.IO server.
Building the frontend with React
To get started, open the terminal and run the following command to generate a new React application within a directory named "client":
npx create-react-app client
The next step is equipping our React application with the tools for real-time communication and user interaction. Add the following package:
- socket.io-client: This will enable the React application to connect to the Socket.IO server we created earlier.
Navigate to the React project directory (client
) in the terminal and run the following command:
npm install socket.io
With the project setup completed, navigate to the src
folder within our React project. In the next section, we'll set up the Socket.IO client to establish real-time communication with the backend server and develop the user interface component to handle user input.
Connecting React to the server and emitting events
Let's connect the React app to the backend server and listen to the Socket.IO event we created earlier. Clean up the src/App.js
file and add the following snippets:
#client/src/App.js
import "./App.css";
import io from "socket.io-client";
import { useState } from "react";
const socket = io.connect("http://localhost:3001");
function App() {
const [username, setUsername] = useState("");
const [room, setRoom] = useState("");
const [userEmail, setUserEmail] = useState("");
const joinRoom = () => {
if (username !== "" && room !== "") {
const userDetails = {
username: username,
email: userEmail,
room: room,
};
socket.emit("join_room", userDetails);
}
};
return (
<div className="App">
<div className="joinChatContainer">
<h3>Join Room</h3>
<input
type="text"
value={username}
placeholder="Name"
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="email"
placeholder="Email"
required
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
/>
<input
type="text"
placeholder="Room"
value={room}
onChange={(e) => setRoom(e.target.value)}
/>
<button onClick={joinRoom}>Join</button>
</div>
</div>
);
}
export default App;
In the above snippet, the socket
constant establishes a connection to the Socket.IO server running on http://localhost:3001
.
The joinRoom
function validates that both username and room are provided. If valid, it creates a userDetails
object containing the information and then emits a "join_room" event to the server, sending the userDetails
object for further processing. Start the react application, and in the browser, it should look like the image below:
After providing the username and desired room, click the Join button. The console will display the submitted details as the 'join_room' event logs the user information.
Now that we’ve successfully emitted and handled an event from React on the server, it's time to build a chat room component to demonstrate real-time interactions between users in the same room.
Implementing real-time features
To showcase real-time communication, let’s create a dynamic chat room component. In the src
folder within the React project, create a new file named Chat.js
. Within this file, add the following code snippets:
#client/src/Chat.js
import { useEffect, useState } from "react";
import ScrollToBottom from "react-scroll-to-bottom";
function Chat({ socket, username, room, email }) {
const [currentMsg, setCurretMsg] = useState("");
const [messages, setMessages] = useState([]);
const sendMessage = async () => {
if (currentMsg !== "") {
const messageBody = {
id: socket.id,
room: room,
email: email,
sender: username,
message: currentMsg,
time:
new Date(Date.now()).getHours() +
":" +
new Date(Date.now()).getMinutes(),
};
await socket.emit("send_message", messageBody);
setMessages((list) => [...list, messageBody]);
setCurretMsg("");
}
};
return ();
}
export default Chat;
The snippet above does the following:
- It uses
useState
from React to manage two state variables:-
currentMsg
: Stores the user's current message draft. -
messages
: Maintains an array of all messages displayed in the chat component.
-
- The
sendMessage
function handles sending messages. It first checks if a message is written. If there is a message, it creates amessageBody
object containing sender details, message content, and a timestamp. - It then emits the "send_message" event to the server, sending the message data.
Rendering the chat component's UI
In order to render the user interface elements. Add the following snippet to the return()
function of the Chat
.js
component
:
#client/src/Chat.js
<div className="chat-window">
<div className="">
<p>Live Chat</p>
</div>
<div className="chat-body">
{messages &&
messages.map((msg) => {
return (
<div
className="message"
id={username === msg.sender ? "you" : "other"}
>
<div>
<div className="message-content">
<p>{msg.message}</p>
</div>
<div className="message-meta">
<p id="time">{msg.time}</p>
<p id="sender">{msg.sender}</p>
</div>
</div>
</div>
);
})}
</div>
<div className="chat-footer">
<input
type="text"
placeholder="Hey..."
onChange={(e) => setCurretMsg(e.target.value)}
/>
<button onClick={sendMessage}>►</button>
</div>
</div>
The code above builds the Chat component's UI. It creates a chat window with a header, body, and footer.
The body will display chat history, while the footer contains an input field for composing messages and a button to trigger the sendMessage
function.
Next, import and render the Chat.js
component inside the App.js
file and update the
App.js
file with the following code snippet:
#client/src/App.js
// other imports
import Chat from "./Chat";
const socket = io.connect("http://localhost:3001");
function App() {
// other state constants here
const [showChatBox, setShowChatBox] = useState(false);
const joinRoom = () => {
if (username !== "" && room !== "") {
//userDetails object here
socket.emit("join_room", userDetails);
setShowChatBox(true);
}
};
return (
<div className="App">
{!showChatBox ? (
<div className="joinChatContainer">
<h3>Join Chat</h3>
{/* username and room inputs here */}
{/* Join button here */}
</div>
) : (
<Chat socket={socket} username={username} room={room} />
)}
</div>
);
}
export default App;
The Chat.js
component was imported and conditionally rendered to display the chat interface dynamically based on the showChatBox
state variable. Upon successfully joining a room, users will be welcomed with an interactive chat interface that resembles the following visual:
The dynamic rendering ensures that the chat component is only visible when a user has joined a room, providing an intuitive user experience.
Next, let’s go to the backend server and process the message data sent by users. Within the “send_message” event, add this line of code:
socket.to(data.room).emit("recieve_message", data);
The code above does the following:
-
**socket.to(data.room)**
- targets a specific room on the server. It uses thedata.room
property extracted from the received message object. This ensures that messages are only sent to users in the same room. - Meanwhile,
.emit("recieve_message", data)
emits the "recieve_message" event and reroutes the received message data (data
) to all the users in the same room.
Handling incoming messages
Let's implement the logic to listen to the "receive_message" event and dynamically update the chat interface with the incoming messages. In the Chat.js
component after the sendMessage()
function, add the following code snippet:
#client/src/Chat.js
useEffect(() => {
socket.on("recieve_message", (data) => {
setMessages((list) => [...list, data]);
});
}, [socket]);
Here, the useEffect
hook constantly monitors incoming messages. It establishes a callback function that executes whenever the "receive_message" event is triggered by the Socket.IO server.
Within this callback, the setMessages
function updates the messages
state. Using the spread operator, the incoming message (data
) is appended to the existing list of messages.
This update triggers a re-render of the chat component, ensuring the new message is instantly displayed in the chat window for all users in the same room.
With that, we've just built a fully functional chat application using Socket.IO and React. To enhance user experience even further, let’s implement some real-time notification features like:
Room join alerts: Socket.IO allows us to emit a custom event (e.g., "user_joined") from the server whenever a new user joins a room. We can then listen to the event and update the chat interface accordingly.
Real-time room join alerts
To implement the real-time room join alerts, navigate back to the server (index.js
) and update the “join_room” event with the following snippet:
socket.to(data.room).emit("user_joined", data);
The snippet above does the following:
-
socket.to(data.room)
extracts thedata.room
property from the newly joined user's information and assures that the notification is only broadcasted to users within the same room as the new member. -
.emit("user_joined," data)
emits a custom event named "user_joined" to all connected clients (sockets) within the designated room usingsocket.to
. It transmits the received user data (data
) along with the event.
Next, we’ll listen to the "user_joined" event in our React application and display a notification to reflect the new member's presence. Head over to client/src/App.js
file and modify the joinRoom()
like the below:
#client/src/App.js
const joinRoom = () => {
if (username !== "" && room !== "") {
const userDetails = {
username: username,
room: room,
};
socket.emit("join_room", userDetails);
socket.on("user_joined", (data) => {
alert(`${data.username} joined the room`);
});
setShowChatBox(true);
}
};
Now, whenever a new user joins a room, the server immediately broadcasts a custom event ("user_joined") to all existing users within that same room.
To enhance the user experience even further and keep administrators informed, let's add Novu email notification to the application. Novu is a notification platform that simplifies the process of sending and managing notifications within applications. By setting up Novu to send email alerts to inactive users, you can stay updated on room activity and ensure timely intervention.
Novu email notifications
In this section, we’ll use Novu to send friendly reminders to users who haven't been active in the chat for a while as long as they're still connected to the app.
To access Novu services, first create an account. Then, navigate to Workflows > demo-recent-login > send-email to see a sample of the email that will be sent. We can modify the step controls and payload to match our requirements.
Navigate to the left side of the dashboard, as shown above, to copy your secret key to the "API Keys." In the terminal, install @novu/node packages to help communicate with Novu, and trigger real-time notifications within the Node.js server.
npm install @novu/node
Within the chat application's root directory, create a file named .env
and add the following line of code, replacing "YOUR NOVU SECRET KEY" with the actual Novu secret key we copied earlier:
#.env
NOVU_SECRET_KEY ="Paste your secret key here"
Inside the Chat.js
file within the src directory, add the following snippet after the sendMessage()
function:
//src/Chat.js
const activeChat = () => {
if (document.hidden) {
const payload = {
id: socket.id,
room: room,
sender: username,
email: email,
};
socket.emit("notify-user", payload);
}
};
setInterval(() => {
activeChat();
}, 300000);
The snippet above checks if the user's Chat window is inactive (document.hidden
) and, if so, sends a "notify-user" message to the server every 300 seconds (using setInterval
). The message contains the user's ID, email, and other details.
Next, in the index.js
file, import the Novu module with the following line of code:
#index.js
import { Novu } from '@novu/node';
Then add the following snippet after the Socket.IO initialization in the server(index.js
):
#index.js
const time = new Date();
const novu = new Novu(process.env.NOVU_SECRET_KEY);
const notification = (data) => {
novu.trigger("demo-recent-login", {
to: {
subscriberId: "66be2642d3f9eb69fff3f2ca",
email: data.email,
},
payload: {
header: `Hi ${data.sender} you have an active chat at room ${data.room} login to continue chatting`,
loginDate: JSON.parse(JSON.stringify(time)),
loginLocation: "Unknown",
userFirstName: data.username,
},
});
};
The snippet above demonstrates how to trigger an email notification at a specific interval:
- The
novu.trigger
method is called inside thenotification()
function, specifying the workflow name ("demo-recent-login") and the notification data. - The
to
object defines the recipient details, including their subscriber ID and email address. - The
payload
object defines the content of the email notification, including a personalized header, timestamp, and username.
The placeholder value for the subscriber ID can be replaced with the actual value.
In the server, after the “user_joined” event, listen for the “notify-user” event; call the notification()
function and pass the data
property:
socket.on("notify-user", (data) => {
notification(data);
});
Now, whenever the server receives a "notify-user" event, it invokes the notification()
function, passing the user's data as input. This triggers the email notification process, sending a reminder to the user’s email address.
Novu provides extensive customization options to fine-tune the notifications. Explore the Novu documentation for detailed guidance on tailoring notification settings to specific needs.
Yes! We've built an awesome real-time chat app. Thanks to Novu, users can connect and chat, and admins even get notified via email. But before we call it a wrap, there's one important step—to ensure everything works as expected.
Think of it like this: We've built a beautiful car, but before we hit the track, we need to test the engine, check the tires, and make sure everything works. Debugging and testing are like taking the chat app for a spin—they’ll help us identify errors and fix them for an excellent user experience.
Debugging and error handling
Building a real-time chat app with Socket.IO and React is awesome, but sometimes, things don't go as planned. Here are the common bumps we might encounter and how to fix them:
Common issues and fixes
When working with Socket.IO and React, the following issues can occur:
- Can't connect - Make sure that the Socket.IO server is up and running at the URL specified in our React app.
- Events not firing - Double-check the event names on the server and client sides. A typo or a case-sensitive mistake can block communication.
- Data is inaccurate - Ensure the data sent through Socket.IO events is formatted correctly (like JSON).
- UI not updating - Make sure that the React components listen to the right Socket.IO events and update the UI state accordingly. React DevTools helps us identify issues related to rendering or keeping the app's state in sync.
By anticipating these challenges, we can proactively address them, preventing potential issues and implementing practical solutions.
Debugging techniques in Socket.IO
Having a smooth chat experience depends on the Socket.IO server running perfectly. Here are some techniques we can use to sniff out any bugs:
- Server-side logging: Use a logging library like Winston. It keeps track of everything happening on the server – connections, events, and errors.
- Console logging: Implement log statements throughout the server code to inspect data flow, event triggers, and state changes. This allows us to trace the execution path and identify unexpected behavior.
- Network traffic inspection: Use the browser's developer tools to spy on network traffic and confirm if connections are made, data is sent, and the server responds properly.
By adopting these techniques, we'll understand what's happening on the server and fix any problems that might stop our chat app from working perfectly.
Debugging techniques in React
While building the React application, we'll need to debug UI updates and ensure proper client-side functionality. Here are some effective debugging techniques for React:
- Browser developer tools: The first line of defense for debugging React applications is using the browser's built-in developer tools. Tools like Chrome DevTools offer a powerful suite for inspecting the component hierarchy, tracking state changes, and pinpointing errors.
- React DevTools: While browser developer tools are great for general debugging, consider installing the React DevTools browser extension for deeper insights specific to React.
-
Console logging: Similar to the server side, use
console.log
statements within the React components to inspect data flow, state mutations, and component lifecycle events. - Error handling: Implement proper error handling mechanisms within the React components to catch any exceptions or unexpected behavior. This can be done by displaying user-friendly error messages.
By mastering these techniques, we’ll be equipped to fix any UI glitches and be sure the app displays data perfectly based on real-time updates. Remember to remove those console logs before launching the app.
Remember the beautiful car? We’ve now learned how to debug the engine, check the tires, and fix any errors. It's time to take it for a test drive on different terrains – uphill climbs, sharp turns – to ensure it handles everything users might throw at it.
Testing in Socket.IO and React application
Building a reliable real-time application requires testing strategies to ensure a smooth user experience and robust functionality. Let's discuss different testing approaches to validate the interaction between a Socket.IO and React application:
Unit testing
Unit testing isolates individual components and verifies their behavior in a controlled environment. It can isolate functions responsible for processing data received through Socket.IO events, ensuring that data manipulation and state updates happen as expected.
Jest and React Testing Library are popular tools used to test React applications. These libraries provide tools for mocking dependencies, simulating user interactions, and asserting the expected behavior of our components.
To conduct a unit test in the real-time chat application we just built, head over to the client/src
folder and create a Chat.test.js
file with the following snippet:
#client/src/Chat.test.js
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import Chat from "./Chat";
// Mock the Socket.IO functionality
jest.mock("socket.io-client");
test("renders received messages", () => {
const mockMessages = [
{ message: "Live Chat", sender: "user1", time: "10:20" },
];
const mockSocket = { on: jest.fn() };
// Render the Chat component with mocked props
render(
<Chat
socket={mockSocket}
username="test_user"
room="5"
messages={mockMessages}
/>
);
// Verify message rendering
const message = screen.getByText("Live Chat");
expect(message).toBeInTheDocument();
// Simulate receiving a new message through the socket
mockSocket.on.mock.calls\[0\][1]({
message: "New message!",
sender: "user2",
time: "10:21",
});
// Verify new message rendering after receiving it through the socket
const newMessage = screen.getByText("New message!");
expect(newMessage).toBeInTheDocument();
});
The test here ensures that the chat component in our React application displays received messages correctly. In addition, it does the following:
- It mocks the socket connection to avoid actual network calls during the test.
- The test simulates sending initial messages and a new message through the socket.
- The chat component is rendered with these mocked messages and a mock socket object.
- The test then verifies if the initial messages appear on the screen.
- Finally, it simulates a new message being received and checks if it's also displayed correctly.
Integration testing
Integration testing focuses on how different parts of our application interact with each other.
Implementing integration tests makes us confident that the application's components and functionalities work together when dealing with real-time data exchange.
End-to-End testing
End-to-end testing recreates fundamental user interactions with the application. It involves testing the flow from user actions in the React client to data processing on the server and subsequent updates through Socket.IO.
To implement end-to-end testing in our real-time Chat app, use browser automation tools like Cypress to replicate user actions such as joining rooms, sending messages, and receiving updates. This demonstrates the entire flow, from user interaction to real-time updates in the UI.
We've put our chat app through its paces with various testing approaches. Let's discuss some best practices to keep the Socket.IO and React integration running smoothly.
Best practices for Socket.IO and React integration
Here's a breakdown of best practices to guarantee a smooth user experience and a robust architecture when integrating Socket.IO in a React application:
Optimizing performance
- Minimizing data: When sending messages, focus on the essentials. Use JSON and choose simple data types (numbers and strings) to keep things moving fast.
- Event throttling: If the application sends frequent updates, consider throttling them to avoid overwhelming the server and client.
- Caching: Caching frequently accessed data on both the server and client can improve performance by reducing redundant database queries and network requests.
Ensuring security
- Authentication and authorization: Implement authentication and authorization to keep out unauthorized users.
- Data validation: Always check the format and content of incoming messages, especially for sensitive information. This helps prevent potential security vulnerabilities like data injection attacks.
- Event access control: Control which events users can trigger and listen to. This keeps unauthorized users from messing with things they shouldn't. We can also implement role-based controls to grant permissions based on user privileges.
Maintaining scalability
- Load balancing: As the app grows, use a load balancer to distribute the work among multiple servers.
- Scalable server infrastructure: Consider cloud-based infrastructure to easily scale the server resources up or down as needed. This keeps the app running smoothly even during peak traffic times.
- Performance Monitoring: Keep an eye on the app's performance using tools and metrics. Identify what's slowing things down and make adjustments.
By following these key best practices, the Socket.IO and React application will deliver a fantastic user experience, robust security, and scalable architecture to handle anything thrown at it.
Summary
We've learned to build a real-time chat application using ReactJS and Socket.IO, including setting up the backend with Express and integrating real-time front-end features. We've also seen how integrating Novu enhances the application with real-time notifications, informing admins about user activities.
But we can take it further by allowing users to receive notifications about new messages even when they're not actively using the app. Novu’s notification system also offers customizable alerts and supports various channels, from email to SMS, push notifications, etc., ensuring users stay engaged and up-to-date with minimal effort. Sign up on Novu today and start sending personalized alerts and emails.
Posted on October 2, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.