How to Build a Real-Time Chat App with React.js and Socket.io 2025
Building real-time chat applications has become essential for modern web development. In this comprehensive tutorial, you’ll learn how to create a production-ready chat application from scratch using React.js and Socket.io. This guide covers everything from basic setup to advanced features like user authentication, message persistence, and real-time indicators.
Contents
Real-Time Chat App
By the end of this tutorial, you’ll have created a fully functional chat application with these features:
- Real-time messaging between multiple users
- User authentication with secure login and registration
- Message history stored in MongoDB database
- Online status indicators showing who’s currently active
- Typing indicators to show when users are composing messages
- Room-based conversations for organised discussions
- Message timestamps and user avatars for better UX
- Responsive design that works perfectly on mobile devices
This tutorial is perfect for developers who want to understand real-time web development and create interactive applications that users love.
Prerequisites
Before starting this tutorial, make sure you have:
- Node.js (version 16 or higher) installed on your computer
- Basic React.js knowledge including hooks and components
- Express.js fundamentals understanding
- MongoDB Atlas account (free tier is sufficient)
- Code editor like VS Code for development
Project Architecture Overview
Understanding the architecture before coding saves hours of confusion later. Our chat application follows a modern client-server architecture:
Frontend Architecture
- React.js handles the user interface and user interactions
- Socket.io client manages real-time communication with the server
- Context API manages global state like user authentication
- Styled Components creates beautiful, responsive designs
Backend Architecture
- Node.js with Express.js serves as our web server
- Socket.io server handles real-time WebSocket connections
- MongoDB stores user data, messages, and chat rooms
- JWT tokens provide secure user authentication
Database Design
Our MongoDB database contains three main collections:
- Users collection stores user profiles and authentication data
- Messages collection stores all chat messages with references to users
- Rooms collection (optional) for organizing conversations by topic
This architecture ensures scalability, security, and excellent performance even with many concurrent users.
Setting Up the Development Environment
Let’s create a well-organized project structure that separates frontend and backend code:
mkdir realtime-chat-app
cd realtime-chat-app
mkdir client server
This separation keeps your code organized and makes deployment easier. The client
folder contains React.js code, while the server
folder contains Node.js backend code.
Backend Development with Node.js and Socket.io
Step 1: Initialize the Server Project
Navigate to the server directory and set up the Node.js project:
cd server
npm init -y
npm install express socket.io mongoose bcryptjs jsonwebtoken cors multer dotenv
npm install -D nodemon
Package explanations:
express
creates our web server and API routessocket.io
enables real-time WebSocket communicationmongoose
provides elegant MongoDB object modelingbcryptjs
securely hashes user passwordsjsonwebtoken
creates and verifies JWT authentication tokenscors
enables cross-origin requests between frontend and backenddotenv
manages environment variables securelynodemon
automatically restarts the server during development
Step 2: Create the Main Server File
Create server/index.js
with basic Express and Socket.io setup:
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
cors: {
origin: "http://localhost:3000",
methods: ["GET", "POST"]
}
});
app.use(cors());
app.use(express.json());
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
const PORT = process.env.PORT || 5000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
This code sets up the foundation of our server. We create an HTTP server using Express.js
and attach Socket.io
to it for real-time communication. The CORS
configuration allows our React frontend (running on port 3000) to communicate with the backend (running on port 5000). The MongoDB
connection uses Mongoose
to provide a clean interface for database operations.
Step 3: Create User Database Model
Create server/models/User.js
for user data structure:
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3,
maxlength: 20
},
email: {
type: String,
required: true,
unique: true,
lowercase: true
},
password: {
type: String,
required: true,
minlength: 6
},
avatar: {
type: String,
default: 'https://via.placeholder.com/40'
},
isOnline: {
type: Boolean,
default: false
},
lastSeen: {
type: Date,
default: Date.now
}
}, {
timestamps: true
});
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
userSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
This User model defines the structure for user data in our MongoDB database. The schema includes validation rules like minimum username length and required fields.
The pre('save')
middleware automatically hashes passwords before storing them, ensuring user passwords are never stored in plain text. The comparePassword
method allows us to verify user login credentials securely.
Step 4: Create Message Database Model
Create server/models/Message.js
for storing chat messages:
const messageSchema = new mongoose.Schema({
content: {
type: String,
required: true,
trim: true,
maxlength: 1000
},
sender: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
room: {
type: String,
required: true,
default: 'general'
},
messageType: {
type: String,
enum: ['text', 'image', 'file'],
default: 'text'
},
edited: {
type: Boolean,
default: false
},
editedAt: Date
}, {
timestamps: true
});
module.exports = mongoose.model('Message', messageSchema);
The Message
model stores all chat messages in the database. Each message references a User
through the sender
field, creating a relationship between users and their messages.
The room
field allows organizing messages into different chat rooms. The messageType
enum prepares the system for future features like image and file sharing. The timestamps
option automatically adds createdAt
and updatedAt
fields.
Step 5: Implement User Authentication
Create server/routes/auth.js
for user registration and login:
router.post('/register', async (req, res) => {
try {
const { username, email, password } = req.body;
const existingUser = await User.findOne({
$or: [{ email }, { username }]
});
if (existingUser) {
return res.status(400).json({
message: 'User with this email or username already exists'
});
}
const user = new User({ username, email, password });
await user.save();
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({
message: 'User created successfully',
token,
user: {
id: user._id,
username: user.username,
email: user.email,
avatar: user.avatar
}
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(400).json({ message: 'Invalid credentials' });
}
user.isOnline = true;
await user.save();
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
message: 'Login successful',
token,
user: {
id: user._id,
username: user.username,
email: user.email,
avatar: user.avatar
}
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router;
These authentication routes handle user registration and login. The registration endpoint checks for existing users to prevent duplicates, creates new users with hashed passwords, and returns a JWT token for immediate login.
The login endpoint verifies user credentials, updates their online status, and returns authentication data. JWT tokens expire after 7 days for security while maintaining user convenience.
Step 6: Implement Socket.io Real-time Features
Add this Socket.io code to your server/index.js
file:
...
if (!fs.existsSync('uploads')) {
fs.mkdirSync('uploads');
}
const messageRateLimit = new Map();
const userSockets = new Map();
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET || 'fallback_secret', (err, user) => {
if (err) {
return res.status(403).json({ message: 'Invalid token' });
}
req.user = user;
next();
});
};
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads/')
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB
},
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|pdf|doc|docx|txt/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only images and documents are allowed!'));
}
}
});
app.post('/api/upload', authenticateToken, upload.single('file'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
const fileUrl = `/uploads/${req.file.filename}`;
res.json({
fileUrl,
originalName: req.file.originalname,
size: req.file.size,
mimetype: req.file.mimetype
});
} catch (error) {
console.error('File upload error:', error);
res.status(500).json({ message: 'File upload failed', error: error.message });
}
});
app.use('/uploads', express.static('uploads'));
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('authenticate', async (token) => {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'fallback_secret');
const user = await User.findById(decoded.userId);
if (!user) {
socket.emit('auth_error', 'Invalid user');
return;
}
socket.userId = user._id.toString();
socket.username = user.username;
socket.avatar = user.avatar;
const oldSocketId = userSockets.get(socket.userId);
if (oldSocketId && oldSocketId !== socket.id) {
const oldSocket = io.sockets.sockets.get(oldSocketId);
if (oldSocket) {
oldSocket.disconnect();
}
}
userSockets.set(socket.userId, socket.id);
await User.findByIdAndUpdate(user._id, {
isOnline: true,
lastSeen: new Date()
});
socket.emit('authenticated', {
user: {
id: user._id,
username: user.username,
avatar: user.avatar
}
});
socket.broadcast.emit('user_online', {
userId: user._id,
username: user.username
});
const users = await User.find({})
.select('username avatar isOnline lastSeen')
.sort({ isOnline: -1, lastSeen: -1 })
.limit(50)
.exec();
io.emit('users_list_update', users);
} catch (error) {
console.error('Authentication error:', error);
socket.emit('auth_error', 'Invalid token');
}
});
socket.on('join_room', async (roomName) => {
socket.join(roomName);
socket.currentRoom = roomName;
try {
const messages = await Message.find({ room: roomName })
.populate('sender', 'username avatar')
.sort({ createdAt: -1 })
.limit(20)
.exec();
socket.emit('room_messages', {
messages: messages.reverse(),
hasMore: messages.length === 20
});
} catch (error) {
console.error('Error loading room messages:', error);
socket.emit('error', 'Failed to load room messages');
}
});
socket.on('load_more_messages', async (data) => {
try {
const { room, page = 1, limit = 20 } = data;
const skip = page * limit;
const messages = await Message.find({ room })
.populate('sender', 'username avatar')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.exec();
socket.emit('more_messages', {
messages: messages.reverse(),
hasMore: messages.length === limit,
page: page + 1
});
} catch (error) {
console.error('Error loading more messages:', error);
socket.emit('error', 'Failed to load more messages');
}
});
socket.on('send_message', async (data) => {
try {
if (!socket.userId) {
socket.emit('error', 'Not authenticated');
return;
}
const userId = socket.userId;
const now = Date.now();
const userRateData = messageRateLimit.get(userId) || { count: 0, resetTime: now + 60000 };
if (now > userRateData.resetTime) {
userRateData.count = 0;
userRateData.resetTime = now + 60000;
}
if (userRateData.count >= 30) {
socket.emit('error', 'Rate limit exceeded. Please slow down.');
return;
}
userRateData.count++;
messageRateLimit.set(userId, userRateData);
if (!data.content || data.content.trim().length === 0) {
socket.emit('error', 'Message content cannot be empty');
return;
}
const message = new Message({
content: data.content.trim(),
sender: socket.userId,
room: data.room || 'general',
messageType: data.messageType || 'text'
});
await message.save();
await message.populate('sender', 'username avatar');
const messageData = {
_id: message._id,
content: message.content,
sender: message.sender,
room: message.room,
messageType: message.messageType,
createdAt: message.createdAt
};
io.to(data.room || 'general').emit('new_message', messageData);
} catch (error) {
console.error('Error sending message:', error);
socket.emit('error', 'Failed to send message');
}
});
socket.on('send_private_message', async (data) => {
try {
if (!socket.userId) {
socket.emit('error', 'Not authenticated');
return;
}
const { recipientId, content } = data;
if (!recipientId || !content || content.trim().length === 0) {
socket.emit('error', 'Invalid message data');
return;
}
const recipient = await User.findById(recipientId);
if (!recipient) {
socket.emit('error', 'Recipient not found');
return;
}
const privateRoom = `private_${[socket.userId, recipientId].sort().join('_')}`;
const message = new Message({
content: content.trim(),
sender: socket.userId,
room: privateRoom,
messageType: 'text'
});
await message.save();
await message.populate('sender', 'username avatar');
const messageData = {
_id: message._id,
content: message.content,
sender: message.sender,
room: message.room,
messageType: message.messageType,
createdAt: message.createdAt
};
const recipientSocketId = userSockets.get(recipientId);
if (recipientSocketId) {
io.to(recipientSocketId).emit('new_private_message', messageData);
}
socket.emit('new_private_message', messageData);
} catch (error) {
console.error('Error sending private message:', error);
socket.emit('error', 'Failed to send private message');
}
});
socket.on('join_private_conversation', async (data) => {
try {
if (!socket.userId) {
socket.emit('error', 'Not authenticated');
return;
}
const { recipientId } = data;
if (!recipientId) {
socket.emit('error', 'Recipient ID required');
return;
}
const recipient = await User.findById(recipientId);
if (!recipient) {
socket.emit('error', 'Recipient not found');
return;
}
const privateRoom = `private_${[socket.userId, recipientId].sort().join('_')}`;
socket.join(privateRoom);
const messages = await Message.find({ room: privateRoom })
.populate('sender', 'username avatar')
.sort({ createdAt: -1 })
.limit(20)
.exec();
socket.emit('private_messages', {
messages: messages.reverse(),
room: privateRoom,
recipientId: recipientId,
hasMore: messages.length === 20
});
} catch (error) {
console.error('Error joining private conversation:', error);
socket.emit('error', 'Failed to join private conversation');
}
});
socket.on('get_users', async () => {
try {
if (!socket.userId) {
socket.emit('auth_error', 'Not authenticated');
return;
}
const users = await User.find({
_id: { $ne: socket.userId }
})
.select('username avatar isOnline lastSeen')
.sort({ isOnline: -1, lastSeen: -1 })
.limit(50)
.exec();
socket.emit('users_list', users);
} catch (error) {
console.error('Error getting users list:', error);
socket.emit('error', 'Failed to get users list');
}
});
socket.on('typing_start', (data) => {
if (!socket.userId || !data.room) return;
socket.to(data.room).emit('user_typing', {
userId: socket.userId,
username: socket.username,
room: data.room
});
});
socket.on('typing_stop', (data) => {
if (!socket.userId || !data.room) return;
socket.to(data.room).emit('user_stop_typing', {
userId: socket.userId,
room: data.room
});
});
socket.on('private_typing', (data) => {
if (!socket.userId || !data.recipientId) return;
const recipientSocketId = userSockets.get(data.recipientId);
if (recipientSocketId) {
io.to(recipientSocketId).emit('private_typing', {
userId: socket.userId,
username: socket.username
});
}
});
socket.on('private_stop_typing', (data) => {
if (!socket.userId || !data.recipientId) return;
const recipientSocketId = userSockets.get(data.recipientId);
if (recipientSocketId) {
io.to(recipientSocketId).emit('private_stop_typing', {
userId: socket.userId
});
}
});
socket.on('user_logout', async () => {
if (socket.userId) {
try {
await User.findByIdAndUpdate(socket.userId, {
isOnline: false,
lastSeen: new Date()
});
socket.broadcast.emit('user_offline', {
userId: socket.userId
});
const users = await User.find({})
.select('username avatar isOnline lastSeen')
.sort({ isOnline: -1, lastSeen: -1 })
.limit(50)
.exec();
socket.broadcast.emit('users_list_update', users);
userSockets.delete(socket.userId);
socket.disconnect();
} catch (error) {
console.error('Error during logout:', error);
}
}
});
socket.on('disconnect', async () => {
console.log('User disconnected:', socket.id);
if (socket.userId) {
const currentSocketId = userSockets.get(socket.userId);
if (currentSocketId === socket.id) {
userSockets.delete(socket.userId);
try {
await User.findByIdAndUpdate(socket.userId, {
isOnline: false,
lastSeen: new Date()
});
socket.broadcast.emit('user_offline', {
userId: socket.userId
});
const users = await User.find({})
.select('username avatar isOnline lastSeen')
.sort({ isOnline: -1, lastSeen: -1 })
.limit(50)
.exec();
socket.broadcast.emit('users_list_update', users);
} catch (error) {
console.error('Error updating user offline status:', error);
}
}
}
});
socket.on('error', (error) => {
console.error('Socket error:', error);
});
});
app.use('/api/auth', require('./routes/auth'));
...
This Socket.io implementation handles all real-time communication features. The authenticate
event verifies JWT tokens and sets up user sessions. The join_room
event adds users to chat rooms and loads recent message history in chunks of 20.
The rate limiting prevents users from sending more than 30 messages per minute, protecting against spam. Private messaging creates unique room names by combining user IDs, ensuring consistent private conversations.
File upload functionality allows users to share images and documents with size and type restrictions. The get_users
event provides a list of available users for private messaging.
The send_message
event saves messages to the database and broadcasts them to all room members. Typing indicators provide real-time feedback when users are composing messages.
The disconnect
event updates user offline status and notifies other users.
Step 7: Environment Configuration
Create server/.env
file for secure configuration:
MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/chatapp
JWT_SECRET=your_super_secret_jwt_key_here_make_it_long_and_random
PORT=5001
NODE_ENV=development
Environment variables keep sensitive data secure and separate from your code. The MONGODB_URI
connects to your MongoDB Atlas database. The JWT_SECRET
should be a long, random string for maximum security.
Never commit the .env
file to version control systems like Git, as this could expose your secrets to unauthorized users.
Frontend Development with React.js
Step 1: Initialize React Application
Create the React frontend in the client directory:
cd ../client
npx create-react-app .
npm install socket.io-client axios react-router-dom styled-components
Package explanations:
socket.io-client
connects React to the Socket.io serveraxios
handles HTTP requests for authenticationreact-router-dom
manages navigation between pagesstyled-components
creates beautiful, component-based styles
Step 2: Create Socket.io Context
Create client/src/context/SocketContext.js
for global Socket.io management:
import React, { createContext, useContext, useEffect, useState } from 'react';
import io from 'socket.io-client';
const SocketContext = createContext();
export const useSocket = () => {
const context = useContext(SocketContext);
if (!context) {
throw new Error('useSocket must be used within a SocketProvider');
}
return context;
};
export const SocketProvider = ({ children }) => {
const [socket, setSocket] = useState(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const newSocket = io('http://localhost:5001');
newSocket.on('connect', () => {
setIsConnected(true);
console.log('Connected to server');
});
newSocket.on('disconnect', () => {
setIsConnected(false);
console.log('Disconnected from server');
});
setSocket(newSocket);
return () => {
newSocket.close();
};
}, []);
return (
<SocketContext.Provider value={{ socket, isConnected }}>
{children}
</SocketContext.Provider>
);
};
React Context provides a clean way to share the Socket.io
connection across all components without prop drilling. The useSocket
hook allows any component to access the socket connection and connection status.
The provider automatically connects to the server when the app starts and handles reconnection logic. The cleanup function in useEffect
prevents memory leaks by properly closing socket connections.
Step 3: Build Authentication Component
Create client/src/components/Auth/Login.js
for user login interface:
import React, { useState } from 'react';
import axios from 'axios';
import styled from 'styled-components';
...
const Login = ({ onLogin }) => {
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await axios.post('http://localhost:5000/api/auth/login', formData);
localStorage.setItem('token', response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user));
onLogin(response.data.user, response.data.token);
} catch (error) {
setError(error.response?.data?.message || 'Login failed');
} finally {
setLoading(false);
}
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
return (
<LoginContainer>
<LoginForm onSubmit={handleSubmit}>
<h2>Welcome Back</h2>
{error && <div style={{color: 'red', marginBottom: '1rem'}}>{error}</div>}
<div style={{marginBottom: '1rem'}}>
<input
type="email"
name="email"
placeholder="Email"
value={formData.email}
onChange={handleChange}
required
style={{width: '100%', padding: '0.5rem', borderRadius: '5px', border: '1px solid #ddd'}}
/>
</div>
<div style={{marginBottom: '1rem'}}>
<input
type="password"
name="password"
placeholder="Password"
value={formData.password}
onChange={handleChange}
required
style={{width: '100%', padding: '0.5rem', borderRadius: '5px', border: '1px solid #ddd'}}
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '0.75rem',
backgroundColor: '#667eea',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? 'Logging in...' : 'Login'}
</button>
</LoginForm>
</LoginContainer>
);
};
export default Login;
This login component provides a beautiful, user-friendly authentication interface. The form uses controlled inputs with React state management for real-time validation. Error handling displays meaningful messages when login fails.
The loading state prevents multiple submissions and provides user feedback. Successful login stores the JWT token and user data in localStorage for persistence across browser sessions.

Step 4: Create Chat Room Interface
Create client/src/components/Chat/ChatRoom.js
for the main chat interface:
import React, { useState, useEffect, useRef } from 'react';
import { useSocket } from '../../context/SocketContext';
import styled from 'styled-components';
...
const ChatRoom = ({ user, token, onLogout }) => {
const { socket, isConnected } = useSocket();
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [typing, setTyping] = useState([]);
const [privateTyping, setPrivateTyping] = useState(null);
const [users, setUsers] = useState([]);
const [selectedUser, setSelectedUser] = useState(null);
const [currentRoom, setCurrentRoom] = useState('general');
const [isPrivateChat, setIsPrivateChat] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [loadingMore, setLoadingMore] = useState(false);
const [unreadMessages, setUnreadMessages] = useState(new Map());
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [usersLoaded, setUsersLoaded] = useState(false);
const messagesEndRef = useRef(null);
const fileInputRef = useRef(null);
const typingTimeoutRef = useRef(null);
const privateTypingTimeoutRef = useRef(null);
const retryTimeoutRef = useRef(null);
useEffect(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
}, []);
useEffect(() => {
if (socket && token && isConnected) {
// Clear existing listeners first
socket.removeAllListeners();
socket.emit('authenticate', token);
socket.on('authenticated', (data) => {
console.log('Authenticated:', data.user);
setIsAuthenticated(true);
socket.emit('get_users');
socket.emit('join_room', currentRoom);
retryTimeoutRef.current = setTimeout(() => {
if (!usersLoaded) {
console.log('Retrying user list load...');
socket.emit('get_users');
}
}, 2000);
});
socket.on('auth_error', (message) => {
console.error('Auth error:', message);
setIsAuthenticated(false);
setUsersLoaded(false);
onLogout();
});
socket.on('room_messages', (data) => {
setMessages(data.messages);
setHasMore(data.hasMore);
setCurrentPage(1);
});
socket.on('more_messages', (data) => {
setMessages(prev => [...data.messages, ...prev]);
setHasMore(data.hasMore);
setCurrentPage(data.page);
setLoadingMore(false);
});
socket.on('new_message', (message) => {
setMessages(prev => [...prev, message]);
if (document.hidden && message.sender._id !== user.id) {
if (Notification.permission === 'granted') {
new Notification(`New message from ${message.sender.username}`, {
body: message.messageType === 'file' ? 'Shared a file' : message.content,
icon: '/favicon.ico'
});
}
}
});
socket.on('new_private_message', (message) => {
const messageFromCurrentChat = isPrivateChat && selectedUser && (
message.sender._id === selectedUser._id || message.sender._id === user.id
);
if (messageFromCurrentChat) {
setMessages(prev => [...prev, message]);
} else if (message.sender._id !== user.id) {
setUnreadMessages(prev => {
const newUnread = new Map(prev);
const currentCount = newUnread.get(message.sender._id) || 0;
newUnread.set(message.sender._id, currentCount + 1);
return newUnread;
});
}
if (document.hidden && message.sender._id !== user.id) {
if (Notification.permission === 'granted') {
new Notification(`Private message from ${message.sender.username}`, {
body: message.content,
icon: '/favicon.ico'
});
}
}
});
socket.on('private_messages', (data) => {
setMessages(data.messages);
setCurrentRoom(data.room);
setHasMore(data.hasMore);
setCurrentPage(1);
if (selectedUser) {
setUnreadMessages(prev => {
const newUnread = new Map(prev);
newUnread.delete(selectedUser._id);
return newUnread;
});
}
});
socket.on('users_list', (usersList) => {
setUsers(usersList);
setUsersLoaded(true);
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
}
});
socket.on('users_list_update', (usersList) => {
const filteredUsers = usersList.filter(u => u._id !== user.id);
setUsers(filteredUsers);
setUsersLoaded(true);
});
socket.on('user_typing', (data) => {
if (data.room === currentRoom) {
setTyping(prev => [...prev.filter(t => t.userId !== data.userId), data]);
}
});
socket.on('user_stop_typing', (data) => {
if (data.room === currentRoom) {
setTyping(prev => prev.filter(t => t.userId !== data.userId));
}
});
socket.on('private_typing', (data) => {
if (isPrivateChat && selectedUser && data.userId === selectedUser._id) {
setPrivateTyping(data);
if (privateTypingTimeoutRef.current) {
clearTimeout(privateTypingTimeoutRef.current);
}
privateTypingTimeoutRef.current = setTimeout(() => {
setPrivateTyping(null);
}, 3000);
}
});
socket.on('private_stop_typing', (data) => {
if (isPrivateChat && selectedUser && data.userId === selectedUser._id) {
setPrivateTyping(null);
if (privateTypingTimeoutRef.current) {
clearTimeout(privateTypingTimeoutRef.current);
}
}
});
socket.on('user_online', (data) => {
setUsers(prev => prev.map(u =>
u._id === data.userId ? { ...u, isOnline: true } : u
));
});
socket.on('user_offline', (data) => {
setUsers(prev => prev.map(u =>
u._id === data.userId ? { ...u, isOnline: false } : u
));
});
socket.on('error', (error) => {
console.error('Socket error:', error);
alert(error);
});
socket.on('disconnect', () => {
console.log('Socket disconnected');
setIsAuthenticated(false);
setUsersLoaded(false);
});
socket.on('reconnect', () => {
console.log('Socket reconnected');
setTimeout(() => {
socket.emit('authenticate', token);
}, 100);
});
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (privateTypingTimeoutRef.current) {
clearTimeout(privateTypingTimeoutRef.current);
}
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
}
};
}
}, [socket, token, currentRoom, isPrivateChat, selectedUser, user.id, onLogout, isConnected]);
useEffect(() => {
scrollToBottom();
}, [messages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const loadMoreMessages = () => {
if (hasMore && !loadingMore && socket) {
setLoadingMore(true);
socket.emit('load_more_messages', {
room: currentRoom,
page: currentPage
});
}
};
const handleSendMessage = (e) => {
e.preventDefault();
if (newMessage.trim() && socket && isConnected) {
if (isPrivateChat) {
socket.emit('send_private_message', {
recipientId: selectedUser._id,
content: newMessage
});
} else {
socket.emit('send_message', {
content: newMessage,
room: currentRoom
});
}
setNewMessage('');
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (isPrivateChat) {
socket.emit('private_stop_typing', { recipientId: selectedUser._id });
} else {
socket.emit('typing_stop', { room: currentRoom });
}
}
};
const handleTyping = (e) => {
setNewMessage(e.target.value);
if (socket && isConnected) {
if (e.target.value && e.target.value.trim()) {
if (isPrivateChat) {
socket.emit('private_typing', { recipientId: selectedUser._id });
} else {
socket.emit('typing_start', { room: currentRoom });
}
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
if (isPrivateChat) {
socket.emit('private_stop_typing', { recipientId: selectedUser._id });
} else {
socket.emit('typing_stop', { room: currentRoom });
}
}, 1000);
} else {
if (isPrivateChat) {
socket.emit('private_stop_typing', { recipientId: selectedUser._id });
} else {
socket.emit('typing_stop', { room: currentRoom });
}
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
}
}
};
const handleFileUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('http://localhost:5001/api/upload', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const data = await response.json();
if (response.ok) {
if (isPrivateChat) {
socket.emit('send_private_message', {
recipientId: selectedUser._id,
content: `${data.originalName}|${data.fileUrl}`
});
} else {
socket.emit('send_message', {
content: `${data.originalName}|${data.fileUrl}`,
room: currentRoom,
messageType: 'file'
});
}
} else {
alert('File upload failed: ' + data.message);
}
} catch (error) {
console.error('File upload error:', error);
alert('File upload failed');
}
e.target.value = '';
};
const startPrivateChat = (selectedUserData) => {
setSelectedUser(selectedUserData);
setIsPrivateChat(true);
setMessages([]);
setUnreadMessages(prev => {
const newUnread = new Map(prev);
newUnread.delete(selectedUserData._id);
return newUnread;
});
if (socket) {
socket.emit('join_private_conversation', {
recipientId: selectedUserData._id
});
}
};
const switchToGeneralChat = () => {
setIsPrivateChat(false);
setSelectedUser(null);
setCurrentRoom('general');
setMessages([]);
setPrivateTyping(null);
if (socket) {
socket.emit('join_room', 'general');
}
};
const handleLogout = () => {
if (socket) {
socket.emit('user_logout');
}
onLogout();
};
const getUserInitials = (username) => {
return username ? username.substring(0, 2).toUpperCase() : '??';
};
const renderMessage = (message) => {
const isFileMessage = message.messageType === 'file' || message.content.includes('|/uploads/');
if (isFileMessage) {
const parts = message.content.split('|');
const fileName = parts[0];
const fileUrl = parts[1];
return (
<FileMessage>
<span>π</span>
<FileLink href={fileUrl} target="_blank" rel="noopener noreferrer">
{fileName}
</FileLink>
</FileMessage>
);
}
return <div>{message.content}</div>;
};
return (
<ChatContainer>
<Sidebar>
<SidebarHeader>
<h3>Users</h3>
<span>{isConnected ? 'π’' : 'π΄'}</span>
</SidebarHeader>
<div style={{ padding: '1rem' }}>
<GeneralChatButton
onClick={switchToGeneralChat}
isPrivateChat={isPrivateChat}
>
General Chat
</GeneralChatButton>
</div>
<UsersList>
{!usersLoaded && isConnected && (
<div style={{ textAlign: 'center', padding: '1rem', color: '#666' }}>
Loading users...
</div>
)}
{usersLoaded && users.length === 0 && (
<div style={{ textAlign: 'center', padding: '1rem', color: '#666' }}>
No other users online
</div>
)}
{users.map(userItem => (
<UserItem
key={userItem._id}
active={selectedUser?._id === userItem._id}
onClick={() => startPrivateChat(userItem)}
>
<OnlineIndicator online={userItem.isOnline} />
<UserInitials>
{getUserInitials(userItem.username)}
</UserInitials>
<span>{userItem.username}</span>
{unreadMessages.get(userItem._id) > 0 && (
<NotificationBadge>
{unreadMessages.get(userItem._id)}
</NotificationBadge>
)}
</UserItem>
))}
</UsersList>
</Sidebar>
<MainChat>
<ChatHeader>
<div>
<h2>
{isPrivateChat
? `Private chat with ${selectedUser?.username}`
: `Chat Room - ${currentRoom}`}
</h2>
<p>Welcome, {user.username}!</p>
</div>
<LogoutButton onClick={handleLogout}>
Logout
</LogoutButton>
</ChatHeader>
<MessagesContainer>
{hasMore && (
<LoadMoreButton
onClick={loadMoreMessages}
disabled={loadingMore}
>
{loadingMore ? 'Loading...' : 'Load More Messages'}
</LoadMoreButton>
)}
{messages.map((message) => (
<MessageGroup
key={message._id}
isOwn={message.sender._id === user.id}
>
<Message isOwn={message.sender._id === user.id}>
{renderMessage(message)}
</Message>
<MessageInfo>
{message.sender.username} β’ {new Date(message.createdAt).toLocaleTimeString()}
</MessageInfo>
</MessageGroup>
))}
<div ref={messagesEndRef} />
</MessagesContainer>
{/* Typing indicators */}
{typing.length > 0 && !isPrivateChat && (
<TypingIndicator>
{typing.map(t => t.username).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing...
</TypingIndicator>
)}
{privateTyping && isPrivateChat && (
<TypingIndicator>
{privateTyping.username} is typing...
</TypingIndicator>
)}
<MessageInput>
<FileUpload
id="file-upload"
ref={fileInputRef}
type="file"
onChange={handleFileUpload}
accept="image/*,.pdf,.doc,.docx,.txt"
/>
<FileUploadButton htmlFor="file-upload">
π
</FileUploadButton>
<form onSubmit={handleSendMessage} style={{ display: 'flex', flex: 1, gap: '1rem' }}>
<TextInput
type="text"
value={newMessage}
onChange={handleTyping}
placeholder={isPrivateChat ? "Type a private message..." : "Type your message..."}
disabled={!isConnected}
/>
<SendButton
type="submit"
disabled={!newMessage.trim() || !isConnected}
>
Send
</SendButton>
</form>
</MessageInput>
</MainChat>
</ChatContainer>
);
};
export default ChatRoom;
The ChatRoom
component creates the main chat interface where users send and receive messages. It connects to the Socket.io server through the useSocket
hook and handles all real-time events. The component automatically scrolls to show new messages and displays typing indicators for better user experience.
Messages are styled differently for the current user versus other users, making conversations easy to follow. The cleanup function in useEffect
prevents memory leaks by removing event listeners when the component unmounts.

Step 5: Build Main App Component
Update client/src/App.js
to tie everything together:
import React, { useState, useEffect } from 'react';
import { SocketProvider } from './context/SocketContext';
import Login from './components/Auth/Login';
import ChatRoom from './components/Chat/ChatRoom';
import './App.css';
function App() {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
useEffect(() => {
const savedToken = localStorage.getItem('token');
const savedUser = localStorage.getItem('user');
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
}
}, []);
const handleLogin = (userData, userToken) => {
setUser(userData);
setToken(userToken);
};
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null);
setToken(null);
};
return (
<SocketProvider>
<div className="App">
{user && token ? (
<ChatRoom user={user} token={token} onLogout={handleLogout} />
) : (
<Login onLogin={handleLogin} />
)}
</div>
</SocketProvider>
);
}
export default App;
The main App
component manages the overall application state and routing between login and chat interfaces. It checks for existing authentication data in localStorage
when the app loads, allowing users to stay logged in across browser sessions.
The SocketProvider
wraps the entire app to provide Socket.io
access to all components. The conditional rendering shows either the login form or chat room based on authentication status.
Troubleshooting Common Issues
Issue 1: CORS Errors
Symptoms: Console errors about blocked cross-origin requests
Solution: Ensure your Socket.io server configuration includes proper CORS settings:
const io = socketIo(server, {
cors: {
origin: "http://localhost:3000",
methods: ["GET", "POST"],
credentials: true
}
});
CORS errors are common when the frontend and backend run on different ports. The configuration above allows the React app (port 3000) to communicate with the Express server (port 5000).
Issue 2: Messages Not Appearing
Symptoms: Messages send but don’t appear for other users
Solution: Check your Socket.io event handling and database operations:
- Verify users join the same room using
socket.join(roomName)
- Check that message broadcasting uses
io.to(roomName).emit()
- Ensure database connections are stable and operations complete
- Confirm Socket.io client event listeners are properly registered
Issue 3: Memory Leaks
Symptoms: Application slows down over time or browser crashes
Solution: Always clean up Socket.io event listeners in React components:
useEffect(() => {
if (socket) {
socket.on('new_message', handleNewMessage);
socket.on('user_typing', handleTyping);
return () => {
socket.off('new_message', handleNewMessage);
socket.off('user_typing', handleTyping);
};
}
}, [socket]);
Memory leaks occur when event listeners accumulate without cleanup. The return function in useEffect removes listeners when components unmount.
Preparing for Production
Before deploying your chat application, make several important changes for production readiness.
Backend Deployment Setup
Update your server/package.json
scripts for production:
{
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"build": "echo 'No build step required for Node.js'"
}
}
Create production environment variables in your hosting platform:
MONGODB_URI
: Your MongoDB Atlas production connection stringJWT_SECRET
: A strong, random secret (different from development)NODE_ENV
: Set to ‘production’PORT
: Usually provided by the hosting platform
Frontend Production Build
Build your React application for production:
cd client
npm run build
This creates an optimized build folder with compressed assets, improved performance, and production-ready code.
Conclusion
We’ve successfully built a comprehensive real-time chat application that demonstrates the fundamental architecture and implementation patterns used in modern web development. This project combines Node.js and Express.js for server-side logic, Socket.io for real-time WebSocket communication, MongoDB for data persistence, and React.js for a responsive user interface.
The application includes essential features like JWT-based authentication, private messaging, file uploads, message pagination, and online status tracking.
You can access the complete working code on GitHub.