Part 2 - WebSockets with Next.js: Real-Time Chat

·24 min read

blog/websockets-nextjs-part-2

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 duplicates
  • user: Identifies who sent the message
  • text: The actual message content
  • timestamp: When the message was sent
  • roomId: 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:

  1. Displaying the connection status
  2. Rendering the list of messages using the ChatMessage component
  3. Providing an input field for new messages
  4. Handling message submission
  5. 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:

  1. username: Stores the user’s chosen username
  2. isJoined: 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:

  1. Better Data Organization: Each message now contains all relevant information, making it easier to display and manage.
  2. User Identification: Messages are associated with specific users, allowing for personalized displays.
  3. Timestamps: We can now show when each message was sent, improving the chat experience.
  4. Preparation for Future Features: The roomId field sets us up for implementing chat rooms later.
  5. Improved Code Organization: By splitting the code into smaller components, it’s now more readable and maintainable.
  6. 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:

  1. We create a users Map to store connected users.
  2. When a user joins (triggered by a “user joined” event), we add them to the users Map.
  3. When a user disconnects, we remove them from the users Map.
  4. 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:

  1. The current user’s name in the chat interface
  2. A list of all online users, with the current user highlighted
  3. 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:

  1. Define Event Types: Both on the server and client, you define custom event types (e.g., ‘chat message’, ‘user joined’, ‘update users’).

  2. Emit Events: You can send (emit) these events from either the server or the client.

  3. Listen for Events: On both ends, you set up listeners for these events to handle incoming data.

  4. 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

  1. 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,
  };
};
  1. 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:

  1. 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.

  2. 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:

  1. Open the chat in two different browser windows.
  2. In one window, start typing a message but don’t send it. Verify that the typing indicator appears in the other window.
  3. Send the message and check that the typing indicator disappears immediately in the other window.
  4. 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:

  1. Organized conversations: Users can join rooms based on interests or topics.
  2. Scalability: Rooms help manage large numbers of users by dividing them into smaller groups.
  3. 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:

  1. Create a component to display the list of available rooms.
  2. Add a form to create new rooms.
  3. Implement a way to switch between rooms, updating the chat display accordingly.
  4. Show the current room name in the chat interface.
  5. 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!

Enjoyed this article? Subscribe for more!

Stay Updated

Get my new content delivered straight to your inbox. No spam, ever.

Related PostsWebSockets, Development, Nextjs, Socketio

Pedro Alonso

I'm a software developer and consultant. I help companies build great products. Contact me by email.

Get the latest articles delivered straight to your inbox.