How to Build a Real-Time Chat App with React.js and Socket.io 2025

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.

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 routes
  • socket.io enables real-time WebSocket communication
  • mongoose provides elegant MongoDB object modeling
  • bcryptjs securely hashes user passwords
  • jsonwebtoken creates and verifies JWT authentication tokens
  • cors enables cross-origin requests between frontend and backend
  • dotenv manages environment variables securely
  • nodemon 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 server
  • axios handles HTTP requests for authentication
  • react-router-dom manages navigation between pages
  • styled-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.

Real Time Chat App React & Node.js

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:

  1. Verify users join the same room using socket.join(roomName)
  2. Check that message broadcasting uses io.to(roomName).emit()
  3. Ensure database connections are stable and operations complete
  4. 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 string
  • JWT_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.

Leave a Comment