Welcome back to our WebSockets with Next.js series! In Part 1, we laid the groundwork by setting up a basic WebSocket connection and implementing a simple broadcast feature. Now, we’re ready to take our chat application to the next level.
In this second part, we’ll dive deeper into WebSocket concepts and enhance our chat application with more robust features. We’ll explore key Socket.IO functionalities and patterns, demonstrating how they can significantly improve user experience and application functionality.
By the end of this tutorial, you’ll have a solid understanding of how to implement advanced real-time features in your Next.js applications using WebSockets. We’ll cover topics such as structured messaging, user presence, chat rooms, and more. Let’s get started by improving our message structure!
Table of Contents
1. Improving Message Structure
In Part 1, we used a simple string-based message system. Now, we’ll enhance our message structure to include more information and improve our chat functionality.
1.1 Introducing Structured Message Objects
First, let’s define a more comprehensive message structure:
interface Message {
id: string;
user: string;
text: string;
timestamp: Date;
roomId?: string;
}
This structure provides several benefits:
id
: Unique identifier for each message, useful for React keys and preventing duplicatesuser
: Identifies who sent the messagetext
: The actual message contenttimestamp
: When the message was sentroomId
: Optional field for when we implement chat rooms
1.2 Updating the Server
Let’s modify our server.js
to handle this new message structure:
const { createServer } = require("http");
const { parse } = require("url");
const next = require("next");
const { Server } = require("socket.io");
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
const io = new Server(server);
io.on("connection", (socket) => {
console.log("A client connected");
socket.on("chat message", (msg) => {
console.log("Message received:", msg);
// Ensure the message has a timestamp
const messageWithTimestamp = {
...msg,
timestamp: msg.timestamp || new Date(),
};
io.emit("chat message", messageWithTimestamp);
});
socket.on("disconnect", () => {
console.log("A client disconnected");
});
});
server.listen(3000, (err) => {
if (err) throw err;
console.log("> Ready on http://localhost:3000");
});
});
1.3 Updating the Client
Now, let’s update our useSocket
hook to handle the new message structure:
import { useEffect, useState } from 'react';
import io, { Socket } from 'socket.io-client';
import { v4 as uuidv4 } from 'uuid';
interface Message {
id: string;
user: string;
text: string;
timestamp: Date;
roomId?: string;
}
export const useSocket = (username: string) => {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
// We're not passing any arguments to io() because we're using
// the same origin for both the client and server in this example.
// In a production environment, you might need to specify the server URL.
const socketIo = io();
socketIo.on('connect', () => {
setIsConnected(true);
});
socketIo.on('disconnect', () => {
setIsConnected(false);
});
socketIo.on('chat message', (msg: Message) => {
setMessages((prevMessages) => [...prevMessages, msg]);
});
setSocket(socketIo);
return () => {
socketIo.disconnect();
};
}, []);
const sendMessage = (text: string, roomId?: string) => {
if (socket) {
const message: Message = {
id: uuidv4(),
user: username,
text,
timestamp: new Date(),
roomId,
};
socket.emit('chat message', message);
}
};
return { isConnected, messages, sendMessage };
};
1.4 Refactoring the Main Chat Component
Let’s refactor our Home
component from src/app/page.tsx
to use the new message structure and split it into smaller, more manageable components:
First, create a new file src/components/ChatMessage.tsx
:
import React from 'react';
interface Message {
id: string;
user: string;
text: string;
timestamp: Date;
roomId?: string;
}
interface ChatMessageProps {
message: Message;
isOwnMessage: boolean;
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => (
<div className={`mb-2 ${isOwnMessage ? 'text-right' : 'text-left'}`}>
<span className={`inline-block p-2 rounded-lg ${isOwnMessage ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800'}`}>
<strong>{message.user}: </strong>{message.text}
</span>
<div className="text-xs text-gray-500 mt-1">
{new Date(message.timestamp).toLocaleTimeString()}
</div>
</div>
);
export default ChatMessage;
This ChatMessage
component is responsible for rendering individual chat messages. It takes a message
object and an isOwnMessage
boolean as props. The component applies different styling based on whether the message was sent by the current user or someone else, making it easy to visually distinguish between sent and received messages. It also displays the username and timestamp for each message, enhancing the chat experience.
Next, create a new file src/components/ChatInterface.tsx
:
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { useSocket } from '../hooks/useSocket';
import ChatMessage from './ChatMessage';
interface Message {
id: string;
user: string;
text: string;
timestamp: Date;
roomId?: string;
}
interface ChatInterfaceProps {
username: string;
}
const ChatInterface: React.FC<ChatInterfaceProps> = ({ username }) => {
const { isConnected, messages, sendMessage } = useSocket(username);
const [inputMessage, setInputMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputMessage.trim()) {
sendMessage(inputMessage);
setInputMessage('');
}
};
return (
<div className="w-full max-w-md bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold mb-4 text-center">WebSocket Chat Demo</h1>
<div className={`mb-4 text-center ${isConnected ? 'text-green-500' : 'text-red-500'}`}>
{isConnected ? 'Connected' : 'Disconnected'}
</div>
<div className="mb-4 h-64 overflow-y-auto border border-gray-300 rounded p-2">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} isOwnMessage={msg.user === username} />
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className="flex">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
className="flex-grow mr-2 p-2 border border-gray-300 rounded"
placeholder="Type a message..."
/>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
disabled={!isConnected}
>
Send
</button>
</form>
</div>
);
};
export default ChatInterface;
The ChatInterface
component serves as the main chat interface. It uses the useSocket
hook to manage the WebSocket connection and message state. This component handles:
- Displaying the connection status
- Rendering the list of messages using the
ChatMessage
component - Providing an input field for new messages
- Handling message submission
- Auto-scrolling to the latest message
By separating this logic into its own component, we improve code organization and make it easier to manage the chat functionality independently of the rest of the application.
Finally, update your src/app/page.tsx
:
'use client';
import { useState } from 'react';
import ChatInterface from '../components/ChatInterface';
export default function Home() {
const [username, setUsername] = useState('');
const [isJoined, setIsJoined] = useState(false);
const handleJoinChat = () => {
if (username.trim()) {
setIsJoined(true);
}
};
if (!isJoined) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100">
<div className="bg-white p-8 rounded shadow-md">
<h2 className="text-2xl font-bold mb-4">Enter your username</h2>
<input
type="text"
className="border rounded px-4 py-2 w-full mb-4"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleJoinChat();
}
}}
/>
<button
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
onClick={handleJoinChat}
>
Join Chat
</button>
</div>
</div>
);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-100">
<ChatInterface username={username} />
</main>
);
}
This is our main page component, which now handles user registration before displaying the chat interface. It manages two pieces of state:
username
: Stores the user’s chosen usernameisJoined
: Tracks whether the user has joined the chat
The component conditionally renders either a login form or the ChatInterface
based on the isJoined
state. This setup allows us to ensure that users provide a username before entering the chat, improving user identification in the application.
The login form includes both button click and Enter key press handling for a smoother user experience. Once a user joins, their username is passed to the ChatInterface
component, connecting all parts of our application.
Benefits of the New Structure
This improved message structure and component refactoring bring several benefits:
- Better Data Organization: Each message now contains all relevant information, making it easier to display and manage.
- User Identification: Messages are associated with specific users, allowing for personalized displays.
- Timestamps: We can now show when each message was sent, improving the chat experience.
- Preparation for Future Features: The
roomId
field sets us up for implementing chat rooms later. - Improved Code Organization: By splitting the code into smaller components, it’s now more readable and maintainable.
- Reusability: The
ChatMessage
component can be easily reused in other parts of the application if needed.
In the next section, we’ll build on this foundation to implement user presence, allowing us to see who’s currently online in our chat application.
2. Implementing User Presence
One of the key features of a real-time chat application is the ability to see who’s currently online. This feature, known as user presence, enhances the user experience by showing who’s available for conversation. In this section, we’ll implement user presence in our chat application using Socket.IO.
2.1 Concept: Tracking Connected Users
User presence in a WebSocket application involves keeping track of connected users in real-time. When a user connects to the chat, we’ll add them to a list of online users. When they disconnect, we’ll remove them from the list. Socket.IO makes this process straightforward by providing events for connections and disconnections.
2.2 Updating the Server
Let’s modify our server code to track connected users. We’ll use a Map to store user information, with the socket ID as the key.
Update your server.js
file:
const { createServer } = require("http");
const { parse } = require("url");
const next = require("next");
const { Server } = require("socket.io");
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
const users = new Map();
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
const io = new Server(server);
io.on("connection", (socket) => {
console.log("A client connected");
socket.on("user joined", (username) => {
users.set(socket.id, username);
io.emit("update users", Array.from(users.values()));
});
socket.on("chat message", (msg) => {
console.log("Message received:", msg);
io.emit("chat message", msg);
});
socket.on("disconnect", () => {
console.log("A client disconnected");
users.delete(socket.id);
io.emit("update users", Array.from(users.values()));
});
});
server.listen(3000, (err) => {
if (err) throw err;
console.log("> Ready on http://localhost:3000");
});
});
In this updated server code:
- We create a
users
Map to store connected users. - When a user joins (triggered by a “user joined” event), we add them to the
users
Map. - When a user disconnects, we remove them from the
users
Map. - After any change in the user list, we emit an “update users” event with the current list of users.
2.3 Updating the Client
Now, let’s update our client-side code to handle user presence. We’ll modify the useSocket
hook and create a new component to display the list of online users.
First, update the useSocket
hook in src/hooks/useSocket.ts
:
import { useEffect, useState, useCallback } from 'react';
import io, { Socket } from 'socket.io-client';
import { v4 as uuidv4 } from 'uuid';
interface Message {
id: string;
user: string;
text: string;
timestamp: Date;
roomId?: string;
system?: boolean;
}
export const useSocket = (username: string) => {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [users, setUsers] = useState<string[]>([]);
const addSystemMessage = useCallback((text: string, roomId?: string) => {
setMessages((prevMessages) => [
...prevMessages,
{
id: uuidv4(),
user: 'System',
text,
timestamp: new Date(),
roomId,
system: true,
},
]);
}, []);
useEffect(() => {
const socketIo = io();
socketIo.on('connect', () => {
setIsConnected(true);
socketIo.emit('user joined', username);
});
socketIo.on('disconnect', () => {
setIsConnected(false);
});
socketIo.on('chat message', (msg: Message) => {
setMessages((prevMessages) => [...prevMessages, msg]);
});
socketIo.on('update users', (updatedUsers: string[]) => {
setUsers(updatedUsers);
});
socketIo.on('user joined', (joinedUsername: string) => {
addSystemMessage(`${joinedUsername} has joined the chat.`);
});
socketIo.on('user left', (leftUsername: string) => {
addSystemMessage(`${leftUsername} has left the chat.`);
});
setSocket(socketIo);
return () => {
socketIo.disconnect();
};
}, [username, addSystemMessage]);
const sendMessage = (text: string) => {
if (socket) {
const message: Message = {
id: uuidv4(),
user: username,
text,
timestamp: new Date(),
};
socket.emit('chat message', message);
}
};
return { isConnected, messages, sendMessage, users };
};
Now, create a new component to display the list of online users. Create a file src/components/OnlineUsers.tsx
:
import React from 'react';
interface OnlineUsersProps {
users: string[];
currentUser: string;
}
const OnlineUsers: React.FC<OnlineUsersProps> = ({ users, currentUser }) => {
return (
<div className="bg-gray-100 p-4 rounded-lg">
<h2 className="text-lg font-semibold mb-2">Users</h2>
<ul>
{users.map((user, index) => (
<li key={index} className={user === currentUser ? 'font-bold' : ''}>
{user} {user === currentUser && '(me)'}
</li>
))}
</ul>
</div>
);
};
export default OnlineUsers;
2.4 Displaying the Current User and Online Users
Finally, let’s update our ChatInterface
component to include the current user’s name and the list of online users. Update src/components/ChatInterface.tsx
:
import React, { useState, useEffect, useRef } from 'react';
import { useSocket } from '../hooks/useSocket';
import ChatMessage from './ChatMessage';
import OnlineUsers from './OnlineUsers';
interface ChatInterfaceProps {
username: string;
}
const ChatInterface: React.FC<ChatInterfaceProps> = ({ username }) => {
const { isConnected, messages, sendMessage, users } = useSocket(username);
const [inputMessage, setInputMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputMessage.trim()) {
sendMessage(inputMessage);
setInputMessage('');
}
};
return (
<div className="flex w-full max-w-4xl bg-white rounded-lg shadow-md p-6">
<div className="flex-grow mr-4">
<h1 className="text-2xl font-bold mb-4">WebSocket Chat Demo</h1>
<div className={`mb-4 ${isConnected ? 'text-green-500' : 'text-red-500'}`}>
{isConnected ? 'Connected' : 'Disconnected'}
</div>
<div className="mb-4 h-64 overflow-y-auto border border-gray-300 rounded p-2">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} isOwnMessage={msg.user === username} />
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className="flex">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
className="flex-grow mr-2 p-2 border border-gray-300 rounded"
placeholder="Type a message..."
/>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
disabled={!isConnected}
>
Send
</button>
</form>
</div>
<OnlineUsers users={users} currentUser={username} />
</div>
);
};
export default ChatInterface;
With these changes, our chat application now displays:
- The current user’s name in the chat interface
- A list of all online users, with the current user highlighted
- Real-time updates when users join or leave the chat
This implementation of user presence enhances the chat experience by providing users with context about who they’re chatting with and who’s available for conversation. It demonstrates the power of real-time communication with Socket.IO, updating the user list instantly across all connected clients whenever someone joins or leaves.
Top Tip: Understanding the WebSocket Communication Pattern
At the core of working with WebSockets and Socket.IO is a simple yet powerful pattern:
-
Define Event Types: Both on the server and client, you define custom event types (e.g., ‘chat message’, ‘user joined’, ‘update users’).
-
Emit Events: You can send (emit) these events from either the server or the client.
-
Listen for Events: On both ends, you set up listeners for these events to handle incoming data.
-
Choose the Scope: When emitting an event, you decide who receives it:
- To all connected clients (broadcast)
- To a specific client (using their socket ID)
- To a group of clients (using room IDs, which we’ll cover later)
This pattern allows for flexible, real-time communication. You can create any custom event type you need, emit it when appropriate, and handle it on the receiving end. This flexibility is what makes Socket.IO so powerful for building real-time features.
Remember: The key is to keep your event types consistent between the server and client, and to carefully manage who should receive each event.
In the next section, we’ll further improve our chat application by adding typing indicators, another common feature in modern chat applications.
3. Enhancing User Experience with Typing Indicators
Typing indicators are a common feature in modern chat applications that significantly enhance the user experience. They provide real-time feedback about who is currently typing, creating a more interactive and engaging chat environment.
3.1 Concept: Real-Time Typing Status
Typing indicators work by sending frequent updates about a user’s typing status to all other users in the chat. These updates are typically sent when a user starts typing and when they stop. In Socket.IO, we can use “volatile” events for this purpose, which are perfect for frequent, low-priority updates that can be safely dropped if the connection is unstable.
3.2 Implementing Typing Indicators on the Server
Let’s update our server.js
to handle typing events:
io.on("connection", (socket) => {
// ... existing code ...
socket.on("typing", (username) => {
socket.broadcast.emit("user typing", username);
});
socket.on("stop typing", (username) => {
socket.broadcast.emit("user stop typing", username);
});
});
3.3 Updating the Client for Typing Indicators
Now, let’s modify our useSocket
hook to emit typing events and handle incoming typing statuses:
export const useSocket = (username: string) => {
// ... existing code ...
const [typingUsers, setTypingUsers] = useState<string[]>([]);
useEffect(() => {
// ... existing code ...
socketIo.on('user typing', (typingUsername: string) => {
setTypingUsers(prev => Array.from(new Set([...prev, typingUsername])));
});
socketIo.on('user stop typing', (typingUsername: string) => {
setTypingUsers(prev => prev.filter(user => user !== typingUsername));
});
// ... existing code ...
}, [username, addSystemMessage]);
const sendTypingStatus = (isTyping: boolean) => {
if (socket) {
socket.emit(isTyping ? 'typing' : 'stop typing', username);
}
};
return { isConnected, messages, sendMessage, users, typingUsers, sendTypingStatus };
};
Next, let’s create a component to display typing status. Create a new file src/components/TypingIndicator.tsx
:
import React from 'react';
interface TypingIndicatorProps {
typingUsers: string[];
}
const TypingIndicator: React.FC<TypingIndicatorProps> = ({ typingUsers }) => {
if (typingUsers.length === 0) return null;
const typingText = typingUsers.length === 1
? `${typingUsers[0]} is typing...`
: `${typingUsers.join(', ')} are typing...`;
return (
<div className="text-sm text-gray-500 italic">
{typingText}
</div>
);
};
export default TypingIndicator;
Finally, update the ChatInterface
component to use the typing indicator:
import React, { useState, useEffect, useRef } from 'react';
import { useSocket } from '../hooks/useSocket';
import ChatMessage from './ChatMessage';
import OnlineUsers from './OnlineUsers';
import TypingIndicator from './TypingIndicator';
const ChatInterface: React.FC<ChatInterfaceProps> = ({ username }) => {
const { isConnected, messages, sendMessage, users, typingUsers, sendTypingStatus } = useSocket(username);
const [inputMessage, setInputMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
// ... existing code ...
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputMessage(e.target.value);
sendTypingStatus(e.target.value.length > 0);
};
return (
<div className="flex w-full max-w-4xl bg-white rounded-lg shadow-md p-6">
<div className="flex-grow mr-4">
{/* ... existing code ... */}
<TypingIndicator typingUsers={typingUsers.filter(user => user !== username)} />
<form onSubmit={handleSubmit} className="flex">
<input
type="text"
value={inputMessage}
onChange={handleInputChange}
className="flex-grow mr-2 p-2 border border-gray-300 rounded"
placeholder="Type a message..."
/>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
disabled={!isConnected}
>
Send
</button>
</form>
</div>
<OnlineUsers users={users} currentUser={username} />
</div>
);
};
3.4 Debouncing Typing Events
To prevent overwhelming the server with typing events, we should implement debouncing. Debouncing ensures that we don’t send a typing event for every keystroke, but instead wait for a short pause in typing before sending the event.
Add this debounce function to your useSocket
hook:
const debounce = (func: Function, delay: number) => {
let timeoutId: NodeJS.Timeout;
return (...args: any[]) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
};
export const useSocket = (username: string) => {
// ... existing code ...
// Replace sendTypingStatus() with this
const debouncedSendTypingStatus = useCallback(
debounce((isTyping: boolean) => {
if (socket) {
socket.emit(isTyping ? 'typing' : 'stop typing', username);
}
}, 300),
[socket, username]
);
// Replace sendTypingStatus in the return statement
return { ..., sendTypingStatus: debouncedSendTypingStatus };
};
Also, in the ChatInterface
use the new debouncedSendTypingStatus
function:
const ChatInterface: React.FC<ChatInterfaceProps> = ({ username }) => {
const { isConnected, messages, sendMessage, users, typingUsers, debouncedSendTypingStatus } = useSocket(username);
const [inputMessage, setInputMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputMessage.trim()) {
sendMessage(inputMessage);
setInputMessage('');
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputMessage(e.target.value);
debouncedSendTypingStatus(e.target.value.length > 0); // Change this line
};
// ... rest of the component
3.5 Fixing the Typing Indicator Bug
After implementing the typing indicator feature, you may notice a small bug: the typing indicator doesn’t disappear immediately after sending a message. This creates a confusing user experience where it appears someone is still typing even after their message has been sent. Let’s walk through how to fix this issue.
Understanding the Problem
The bug occurs because we’re not explicitly telling the server to stop typing after a message is sent. The debounced function may still have a pending timeout, causing a delay in updating the typing status.
Step-by-Step Solution
- Update the useSocket Hook
First, let’s modify our useSocket
hook to include a function that immediately stops typing without any debounce delay:
export const useSocket = (username: string) => {
// ... existing code ...
const stopTypingImmediately = useCallback(() => {
if (socket) {
socket.emit('stop typing', username);
}
}, [socket, username]);
// ... existing code ...
return {
// ... other return values ...
debouncedSendTypingStatus,
stopTypingImmediately,
};
};
- Modify the ChatInterface Component
Now, update the ChatInterface
component to use this new stopTypingImmediately
function:
const ChatInterface: React.FC<ChatInterfaceProps> = ({ username }) => {
const {
isConnected,
messages,
sendMessage,
users,
typingUsers,
debouncedSendTypingStatus,
stopTypingImmediately
} = useSocket(username);
// ... existing code ...
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputMessage.trim()) {
sendMessage(inputMessage);
setInputMessage('');
// Immediately stop typing
stopTypingImmediately();
// Also call the debounced version to clear any pending debounced calls
debouncedSendTypingStatus(false);
}
};
// ... rest of the component
};
Explanation of the Fix
This solution addresses the bug in two ways:
-
Immediate Update: By calling
stopTypingImmediately()
, we send an immediate ‘stop typing’ event to the server as soon as a message is sent. This ensures that other users see the typing indicator disappear right away. -
Clearing Pending Updates: We also call
debouncedSendTypingStatus(false)
to clear any pending debounced calls. This prevents any delayed ‘typing’ events from being sent after the message has already been delivered.
By implementing these changes, the typing indicator will now accurately reflect the user’s current status, disappearing as soon as a message is sent and providing a smoother, more intuitive chat experience.
Testing the Fix
After implementing these changes, test the chat application by following these steps:
- Open the chat in two different browser windows.
- In one window, start typing a message but don’t send it. Verify that the typing indicator appears in the other window.
- Send the message and check that the typing indicator disappears immediately in the other window.
- Repeat the process, but this time delete the typed message without sending. Confirm that the typing indicator disappears after a short delay.
This implementation of typing indicators will create a more dynamic and responsive chat experience for your users.
4. Implementing Rooms in Your Chat Application
4.1 Understanding WebSocket Rooms
In a chat application, rooms are a powerful feature that allow users to join specific channels or groups. Rooms in Socket.IO provide a way to broadcast events to a subset of clients, making it easy to implement features like topic-specific chat channels or private group conversations.
Key benefits of using rooms include:
- Organized conversations: Users can join rooms based on interests or topics.
- Scalability: Rooms help manage large numbers of users by dividing them into smaller groups.
- Efficient messaging: You can send messages to specific groups without broadcasting to all connected clients.
4.2 Server-Side Implementation
Let’s update our server.js
to handle room creation, joining, and messaging:
const { createServer } = require("http");
const { parse } = require("url");
const next = require("next");
const { Server } = require("socket.io");
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();
const users = new Map();
const rooms = new Set(['general']); // Start with a default room
app.prepare().then(() => {
const server = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
const io = new Server(server);
io.on("connection", (socket) => {
console.log("A client connected");
socket.on("user joined", (username) => {
users.set(socket.id, { username, room: 'general' });
socket.join('general');
io.to('general').emit("update users", Array.from(users.values())
.filter(user => user.room === 'general')
.map(user => user.username));
socket.broadcast.to('general').emit("user joined", username);
});
socket.on("chat message", (msg) => {
const user = users.get(socket.id);
if (user) {
io.to(user.room).emit("chat message", { ...msg, room: user.room });
}
});
socket.on("create room", (roomName) => {
if (!rooms.has(roomName)) {
rooms.add(roomName);
io.emit("update rooms", Array.from(rooms));
}
});
socket.on("join room", (roomName) => {
const user = users.get(socket.id);
if (user && rooms.has(roomName)) {
const oldRoom = user.room;
socket.leave(oldRoom);
socket.join(roomName);
user.room = roomName;
io.to(oldRoom).emit("update users", Array.from(users.values())
.filter(u => u.room === oldRoom)
.map(u => u.username));
io.to(roomName).emit("update users", Array.from(users.values())
.filter(u => u.room === roomName)
.map(u => u.username));
socket.emit("room joined", roomName);
}
});
socket.on("disconnect", () => {
const user = users.get(socket.id);
if (user) {
console.log("User disconnected:", user.username);
users.delete(socket.id);
io.to(user.room).emit("update users", Array.from(users.values())
.filter(u => u.room === user.room)
.map(u => u.username));
io.to(user.room).emit("user left", user.username);
}
});
});
server.listen(3000, (err) => {
if (err) throw err;
console.log("> Ready on http://localhost:3000");
});
});
This server implementation adds the following room-related functionality:
- Maintains a list of available rooms
- Allows users to create new rooms
- Enables users to join existing rooms
- Sends room-specific messages
- Updates the user list for each room when users join or leave
4.3 Updating the useSocket Hook
Now, let’s modify our useSocket
hook to handle rooms:
import { useEffect, useState, useCallback } from 'react';
import io, { Socket } from 'socket.io-client';
import { v4 as uuidv4 } from 'uuid';
interface Message {
id: string;
user: string;
text: string;
timestamp: Date;
room: string;
}
export const useSocket = (username: string) => {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [currentRoom, setCurrentRoom] = useState('general');
const [availableRooms, setAvailableRooms] = useState(['general']);
useEffect(() => {
const socketIo = io();
socketIo.on('connect', () => {
setIsConnected(true);
socketIo.emit('user joined', username);
});
socketIo.on('disconnect', () => {
setIsConnected(false);
});
socketIo.on('chat message', (msg: Message) => {
setMessages((prevMessages) => [...prevMessages, msg]);
});
socketIo.on('update users', (updatedUsers: string[]) => {
setUsers(updatedUsers);
});
socketIo.on('update rooms', (updatedRooms: string[]) => {
setAvailableRooms(updatedRooms);
});
socketIo.on('room joined', (roomName: string) => {
setCurrentRoom(roomName);
setMessages([]); // Clear messages when joining a new room
});
setSocket(socketIo);
return () => {
socketIo.disconnect();
};
}, [username]);
const sendMessage = useCallback((text: string) => {
if (socket) {
const message: Message = {
id: uuidv4(),
user: username,
text,
timestamp: new Date(),
room: currentRoom,
};
socket.emit('chat message', message);
}
}, [socket, username, currentRoom]);
const createRoom = useCallback((roomName: string) => {
if (socket) {
socket.emit('create room', roomName);
}
}, [socket]);
const joinRoom = useCallback((roomName: string) => {
if (socket) {
socket.emit('join room', roomName);
}
}, [socket]);
return {
isConnected,
messages,
sendMessage,
users,
currentRoom,
availableRooms,
createRoom,
joinRoom
};
};
This updated hook provides the following room-related functionality:
- Keeps track of the current room and available rooms
- Provides methods to create and join rooms
- Updates the message list when joining a new room
- Ensures messages are sent to the current room
4.4 Implementing the UI (Exercise for the Reader)
Now that we have the server-side logic and the useSocket
hook implemented, creating the UI for room functionality is an excellent opportunity to practice what you’ve learned. Here are some suggestions for implementing the room UI:
- Create a component to display the list of available rooms.
- Add a form to create new rooms.
- Implement a way to switch between rooms, updating the chat display accordingly.
- Show the current room name in the chat interface.
- Update the user list to only show users in the current room.
By implementing these UI features, you’ll gain hands-on experience in connecting Socket.IO functionality with React components and state management.
Remember to handle edge cases, such as attempting to join a non-existent room or creating a room with a duplicate name. These scenarios will help you think through the user experience and error handling aspects of your application.
In the next part of this series, we’ll explore more advanced WebSocket concepts and best practices, building on the foundation we’ve established here.
Conclusion and What’s Next
In this part, we’ve significantly enhanced our chat application with structured messaging, user presence, and typing indicators. These features bring us closer to a production-ready chat application.
But we’re not done yet! In Part 3, we’ll explore advanced WebSocket concepts and best practices, including:
- Implementing private messaging
- Robust error handling and reconnection strategies
- Security considerations for WebSocket applications
- Performance optimization for large-scale deployments
- Testing strategies for WebSocket functionality
Stay tuned for the final part of our WebSockets with Next.js series, where we’ll take your real-time application skills to the next level!