PHASE 4 — Topic 14: Working with Rooms — Grouping Users Together

So far, our chat app sends every message to every connected user. That works for a single global chat, but most real apps need to separate conversations, for example, different chat channels, private conversations, or document collaboration sessions. This is exactly what Rooms are for.

What Is a Room?

A room is an arbitrary, named channel that sockets can join and leave. It is a purely server-side concept, meaning the client has no direct access to the list of rooms it belongs to, the server tracks this entirely on its own. A single socket can join multiple rooms at the same time, and one room can contain any number of sockets.

Every socket also automatically joins one room by default the moment it connects: a room named after its own socket.id. This is how io.to(socketId).emit(...), which we used back in Topic 11, actually works internally, it is really just emitting to a room containing exactly one socket.

Joining a Room

You join a room using socket.join(roomName), called on the server:


  socket.on("joinRoom", (roomName: string) => {
    socket.join(roomName);
    console.log(`${socket.id} joined room: ${roomName}`);
  });

join can also accept an array, letting a socket join several rooms in one call: socket.join(["room1", "room2"]).

Leaving a Room

Leaving works the same way, using socket.leave(roomName):


  socket.on("leaveRoom", (roomName: string) => {
    socket.leave(roomName);
    console.log(`${socket.id} left room: ${roomName}`);
  });

You do not need to manually leave rooms when a socket disconnects, Socket.IO automatically removes a socket from every room it was in as soon as it disconnects.

Sending Messages to a Room

Once sockets are grouped into a room, you can broadcast to just that group using to() (or the identical alias in()):


  io.to("room-1").emit("newMessage", data);

This sends the event to every socket currently in "room-1", and nobody else. If you want to send to a room but exclude the sender specifically, call .to() on the socket instead of io:


  socket.to("room-1").emit("newMessage", data);

This follows the exact same sender-exclusion pattern we saw earlier with socket.broadcast.emit, just scoped to one room instead of the whole server.

Updating Our Shared Types

Let's extend src/types/socket.ts to support joining named chat rooms:


    export interface ServerToClientEvents {
        message: (data: { text: string; sender: string }) => void;
        hello: (text: string) => void;
        newMessage: (data: ChatMessage) => void;
        onlineCount: (count: number) => void;
        roomMessage: (data: ChatMessage) => void;
    }

    export interface ClientToServerEvents {
        sendMessage: (data: { text: string; sender: string }) => void;
        sayHello: () => void;
        chatMessage: (data: { text: string; sender: string }) => void;
        joinRoom: (roomName: string) => void;
        leaveRoom: (roomName: string) => void;
        roomChatMessage: (data: {
            room: string;
            text: string;
            sender: string;
        }) => void;
    }

Updating the Server

Add this logic inside io.on("connection", (socket) => { ... }) in server.ts:


    socket.on("joinRoom", (roomName) => {
        socket.join(roomName);
    });

    socket.on("leaveRoom", (roomName) => {
        socket.leave(roomName);
    });

    socket.on("roomChatMessage", (data) => {
        io.to(data.room).emit("roomMessage", {
            id: `${socket.id}-${Date.now()}`,
            text: data.text,
            sender: data.sender,
            timestamp: Date.now(),
        });
    });

Notice we use io.to(data.room).emit(...) here, not socket.to(...), for the same reason we used io.emit(...) in the global chat: it keeps the server as the single source of truth, so the sender also receives their own message back through the same room broadcast, instead of relying on separate local state.

A Minimal Client Example

Here is how a client joins a specific room and sends a room-scoped message:


  useEffect(() => {
    socket.emit("joinRoom", "general");

    return () => {
      socket.emit("leaveRoom", "general");
    };
  }, []);

  function sendRoomMessage(text: string) {
    socket.emit("roomChatMessage", {
      room: "general",
      text,
      sender: username,
    });
  }

And listening for messages from that room:


  useEffect(() => {
    function onRoomMessage(data: ChatMessage) {
      setMessages((prev) => [...prev, data]);
    }

    socket.on("roomMessage", onRoomMessage);

    return () => {
      socket.off("roomMessage", onRoomMessage);
    };
  }, []);

A Common Pattern: Switching Rooms

In apps with multiple chat channels, users often switch from one room to another. The correct pattern is to leave the old room before joining the new one:


  function switchRoom(oldRoom: string, newRoom: string) {
    socket.emit("leaveRoom", oldRoom);
    socket.emit("joinRoom", newRoom);
  }

If you skip leaving the old room, the socket stays subscribed to both, and will keep receiving messages from a room the user thinks they've left.

Checking Which Rooms a Socket Is In

On the server, you can inspect a socket's current rooms through socket.rooms, which is a Set containing every room it has joined, including its own default socket.id room:


  socket.on("joinRoom", (roomName) => {
    socket.join(roomName);
    console.log("Current rooms:", Array.from(socket.rooms));
  });

We actually already used this in the previous post's disconnecting example, looping through socket.rooms to notify others before a socket's room memberships got cleared.

An Important Multi-Server Limitation

Rooms, as we've used them here, only exist on the single server process they were created on. If you later scale your app to run multiple server instances, a room joined on one server instance is not automatically visible to another instance. Solving this requires the Redis Adapter, which we will cover properly in Phase 6. For now, with a single server, everything here works exactly as expected.

Summary

  • Rooms are server-side channels that group sockets together, joined with socket.join() and left with socket.leave()
  • io.to(room).emit(...) sends to everyone in a room; socket.to(room).emit(...) sends to everyone in the room except the sender
  • Sockets automatically leave all rooms on disconnect, no manual cleanup needed there
  • Switching rooms means explicitly leaving the old one before joining the new one
  • socket.rooms gives you the current room list for a socket, useful for debugging and cleanup logic
  • Rooms are single-server only by default; scaling across multiple servers requires the Redis Adapter, covered in Phase 6

In the next post, we cover Namespaces, a related but different way to separate parts of your application over a single Socket.IO connection.

No comments:

Post a Comment

PHASE 4 — Topic 16: Broadcasting Messages to Everyone, or to Specific Users/Rooms

We've used several broadcasting patterns already across earlier posts. This post pulls everything together into one clear reference, and...