PHASE 4 — Topic 13: Building a Simple Real-Time Chat Application (UI with Tailwind)

Time to put everything from Phase 3 together into one complete, working feature: a real chat application. This post combines connection handling, typed events, and both directions of data flow into a single page.

Step 1: Update Our Shared Types

Open src/types/socket.ts and update it with everything our chat app needs:


    export interface ChatMessage {
        id: string;
        text: string;
        sender: string;
        timestamp: number;
    }

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

    export interface ClientToServerEvents {
        sendMessage: (data: { text: string; sender: string }) => void;
        sayHello: () => void;
        chatMessage: (data: { text: string; sender: string }) => void;
    }

    export interface InterServerEvents {
        ping: () => void;
    }

    export interface SocketData {
        userId: string;
    }

Step 2: Update the Server

Open server.ts and update the connection handler to track online users and broadcast chat messages:


    import { createServer } from "http";
    import { parse } from "url";
    import next from "next";
    import { Server } from "socket.io";
    import type {
        ServerToClientEvents,
        ClientToServerEvents,
        InterServerEvents,
        SocketData,
    } from "./src/types/socket";

    const dev = process.env.NODE_ENV !== "production";
    const hostname = "localhost";
    const port = parseInt(process.env.PORT || "3000", 10);

    const app = next({ dev, hostname, port });
    const handle = app.getRequestHandler();

    app.prepare().then(() => {
        const httpServer = createServer((req, res) => {
            const parsedUrl = parse(req.url!, true);
            handle(req, res, parsedUrl);
        });

        const io = new Server<
            ClientToServerEvents,
            ServerToClientEvents,
            InterServerEvents,
            SocketData
        >(httpServer, {
            path: "/api/socket",
        });

        let onlineCount = 0;

        io.on("connection", (socket) => {
            console.log("A client connected:", socket.id);

            onlineCount++;
            io.emit("onlineCount", onlineCount);

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

            socket.on("sayHello", () => {
                socket.emit("hello", "Hello from the server!");
            });

            socket.on("sendMessage", (data) => {
                console.log("Received:", data.text, "from", data.sender);
            });

            socket.on("disconnect", () => {
                onlineCount--;
                io.emit("onlineCount", onlineCount);
                console.log("Client disconnected:", socket.id);
            });
        });

        httpServer.listen(port, () => {
            console.log(`Server ready on http://${hostname}:${port}`);
        });
    });

Notice we use io.emit("newMessage", ...) here, not socket.broadcast.emit(...). This is intentional for a simple chat UI: instead of managing local state separately for "my own message" versus "others' messages," we let the server be the single source of truth, and every client, including the sender, receives the message the same way, through this one event.

Step 3: Build the Chat UI

Replace src/app/page.tsx with this complete chat interface:


  "use client";

  import { useEffect, useRef, useState } from "react";
  import { socket } from "@/lib/socket";
  import type { ChatMessage } from "@/types/socket";

  export default function Home() {
    const [isConnected, setIsConnected] = useState(false);
    const [onlineCount, setOnlineCount] = useState(0);
    const [messages, setMessages] = useState<ChatMessage[]>([]);
    const [input, setInput] = useState("");
    const [username] = useState(
      () => `User-${Math.floor(Math.random() * 1000)}`
    );

    const bottomRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
      setIsConnected(socket.connected);

      function onConnect() {
        setIsConnected(true);
      }

      function onDisconnect() {
        setIsConnected(false);
      }

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

      function onOnlineCount(count: number) {
        setOnlineCount(count);
      }

      socket.on("connect", onConnect);
      socket.on("disconnect", onDisconnect);
      socket.on("newMessage", onNewMessage);
      socket.on("onlineCount", onOnlineCount);

      return () => {
        socket.off("connect", onConnect);
        socket.off("disconnect", onDisconnect);
        socket.off("newMessage", onNewMessage);
        socket.off("onlineCount", onOnlineCount);
      };
    }, []);

    useEffect(() => {
      bottomRef.current?.scrollIntoView({ behavior: "smooth" });
    }, [messages]);

    function handleSend() {
      if (input.trim() === "") return;
      socket.emit("chatMessage", { text: input, sender: username });
      setInput("");
    }

    return (
      <main className="flex min-h-screen flex-col items-center bg-gray-900 text-white p-4">
        <div className="flex w-full max-w-md items-center justify-between mb-4">
          <span
            className={`text-sm ${isConnected ? "text-green-400" : "text-red-400"
              }`}
          >
            {isConnected ? "Connected" : "Disconnected"}
          </span>
          <span className="text-sm text-gray-400">
            {onlineCount} online
          </span>
        </div>

        <div className="w-full max-w-md flex-1 overflow-y-auto rounded-lg bg-gray-800 p-4 h-96">
          {messages.map((msg) => (
            <div
              key={msg.id}
              className={`mb-2 flex flex-col ${msg.sender === username ? "items-end" : "items-start"
                }`}
            >
              <span className="text-xs text-gray-400">{msg.sender}</span>
              <span
                className={`rounded-lg px-3 py-2 text-sm ${msg.sender === username
                  ? "bg-blue-600"
                  : "bg-gray-700"
                  }`}
              >
                {msg.text}
              </span>
            </div>
          ))}
          <div ref={bottomRef} />
        </div>

        <div className="mt-4 flex w-full max-w-md gap-2">
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && handleSend()}
            placeholder="Type a message..."
            className="flex-1 rounded bg-gray-800 px-3 py-2 text-sm outline-none"
          />
          <button
            onClick={handleSend}
            className="rounded bg-blue-600 px-4 py-2 text-sm hover:bg-blue-700"
          >
            Send
          </button>
        </div>
      </main>
    );
  }

Step 4: Understanding the Key Parts

  • username is generated once using useState(() => ...), so it stays the same for the life of the component instead of regenerating on every re-render
  • bottomRef combined with the second useEffect gives us auto-scroll: every time messages changes, we scroll a hidden div at the bottom into view smoothly
  • Messages are styled differently depending on whether msg.sender === username, giving the classic chat bubble look, your own messages on the right, others on the left
  • onKeyDown lets users press Enter to send, in addition to clicking the button
  • The onlineCount badge updates live as users connect and disconnect, using the events we set up on the server

Step 5: Test With Multiple Tabs

Run npm run dev, then open http://localhost:3000 in two or three separate browser tabs. Send a message from one tab, and you should see it appear instantly in every other tab. Close a tab, and watch the online count decrease in the remaining ones.

A Known Limitation

Right now, messages only exist in memory, inside the messages state on each client, and briefly inside your server's event flow. If you refresh the page, chat history disappears completely, since nothing is being saved anywhere. This is expected at this stage. Adding persistent storage, like a database, is outside the scope of this Socket.IO course, but worth keeping in mind if you extend this project further.

Summary

  • We combined typed events, connection tracking, and bidirectional data flow into one working chat feature
  • The server broadcasts every message to all clients using io.emit, keeping the server as the single source of truth
  • Auto-scroll was added using useRef and a useEffect that runs whenever messages changes
  • Online user count updates live, using the connect/disconnect tracking pattern from the previous post
  • Chat history is not persisted, it only exists in memory for the current session

In the next post, we go into Rooms, learning how to group specific users together so messages can be sent only to a particular group instead of everyone.

No comments:

Post a Comment

PHASE 4 — Topic 13: Building a Simple Real-Time Chat Application (UI with Tailwind)

Time to put everything from Phase 3 together into one complete, working feature: a real chat application. This post combines connection hand...