<CodeLearn/>
実践プロジェクト レッスン4

リアルタイムチャット

WebSocketで双方向通信のチャットアプリを作ろう

プロジェクト概要

WebSocketを使って、複数のユーザーがリアルタイムに メッセージをやり取りできるチャットアプリケーションを構築します。 HTTPのリクエスト・レスポンスモデルとは異なる、双方向通信の仕組みを学びます。

技術スタック

  • Node.js + Express
  • Socket.IO(WebSocketライブラリ)
  • React(フロントエンド)

実装する機能

  • リアルタイムメッセージ送受信
  • ユーザー名の設定
  • 接続中ユーザーの表示
  • タイピングインジケーター

WebSocketとは

HTTP通信では、クライアントがリクエストを送り、サーバーがレスポンスを返す一方向の通信です。WebSocketは、一度接続を確立すると クライアントとサーバーが自由にデータをやり取りできる双方向通信を実現します。

HTTP通信:
  クライアント → リクエスト → サーバー
  クライアント ← レスポンス ← サーバー
  (毎回接続を張り直す)

WebSocket通信:
  クライアント ←→ サーバー
  (一度接続したら双方向にデータを送れる)
  サーバーからクライアントに自発的にデータを送信可能

ステップ1: サーバー側の実装(Socket.IO)

// server.js
import express from "express";
import { createServer } from "http";
import { Server } from "socket.io";

const app = express();
const server = createServer(app);
const io = new Server(server, {
  cors: { origin: "http://localhost:3000" },
});

// 接続中のユーザーを管理
const users = new Map();

io.on("connection", (socket) => {
  console.log("ユーザーが接続:", socket.id);

  // ユーザー名の登録
  socket.on("join", (username) => {
    users.set(socket.id, username);
    // 全員に通知
    io.emit("userList", Array.from(users.values()));
    io.emit("system", username + " が参加しました");
  });

  // メッセージの送受信
  socket.on("message", (data) => {
    io.emit("message", {
      id: Date.now(),
      user: users.get(socket.id),
      text: data.text,
      timestamp: new Date().toISOString(),
    });
  });

  // タイピング通知
  socket.on("typing", () => {
    socket.broadcast.emit("typing", users.get(socket.id));
  });

  socket.on("stopTyping", () => {
    socket.broadcast.emit("stopTyping", users.get(socket.id));
  });

  // 切断時
  socket.on("disconnect", () => {
    const username = users.get(socket.id);
    users.delete(socket.id);
    io.emit("userList", Array.from(users.values()));
    if (username) {
      io.emit("system", username + " が退出しました");
    }
  });
});

server.listen(4000, () => {
  console.log("チャットサーバー起動: http://localhost:4000");
});

ステップ2: クライアント側の実装(React)

// hooks/useChat.ts
"use client";

import { useEffect, useState, useCallback } from "react";
import { io } from "socket.io-client";

const socket = io("http://localhost:4000");

export function useChat(username) {
  const [messages, setMessages] = useState([]);
  const [users, setUsers] = useState([]);
  const [typingUser, setTypingUser] = useState(null);

  useEffect(() => {
    // ユーザー参加
    socket.emit("join", username);

    // メッセージ受信
    socket.on("message", (msg) => {
      setMessages(prev => [...prev, msg]);
    });

    // システムメッセージ
    socket.on("system", (text) => {
      setMessages(prev => [...prev, { id: Date.now(), system: true, text }]);
    });

    // ユーザーリスト更新
    socket.on("userList", setUsers);

    // タイピング表示
    socket.on("typing", setTypingUser);
    socket.on("stopTyping", () => setTypingUser(null));

    return () => {
      socket.off("message");
      socket.off("system");
      socket.off("userList");
      socket.off("typing");
      socket.off("stopTyping");
    };
  }, [username]);

  const sendMessage = useCallback((text) => {
    socket.emit("message", { text });
  }, []);

  const startTyping = useCallback(() => {
    socket.emit("typing");
  }, []);

  const stopTyping = useCallback(() => {
    socket.emit("stopTyping");
  }, []);

  return { messages, users, typingUser, sendMessage, startTyping, stopTyping };
}

ステップ3: チャットUIの実装

// components/ChatRoom.tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { useChat } from "@/hooks/useChat";

export function ChatRoom({ username }) {
  const { messages, users, typingUser, sendMessage, startTyping, stopTyping } = useChat(username);
  const [input, setInput] = useState("");
  const messagesEndRef = useRef(null);
  const typingTimeout = useRef(null);

  // 自動スクロール
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    sendMessage(input.trim());
    setInput("");
    stopTyping();
  };

  const handleInput = (e) => {
    setInput(e.target.value);
    startTyping();
    clearTimeout(typingTimeout.current);
    typingTimeout.current = setTimeout(stopTyping, 1000);
  };

  return (
    <div className="flex h-screen">
      {/* ユーザーリスト */}
      <aside className="w-48 bg-gray-900 border-r border-gray-800 p-4">
        <h2 className="font-bold text-sm mb-3">接続中 ({users.length})</h2>
        {users.map(u => (
          <div key={u} className="text-sm text-gray-400 py-1">{u}</div>
        ))}
      </aside>

      {/* メッセージエリア */}
      <div className="flex-1 flex flex-col">
        <div className="flex-1 overflow-y-auto p-4 space-y-3">
          {messages.map(msg => (
            <div key={msg.id} className={msg.system ? "text-center text-gray-500 text-sm" : ""}>
              {msg.system ? msg.text : (
                <div>
                  <span className="font-bold text-indigo-400">{msg.user}</span>
                  <span className="text-xs text-gray-500 ml-2">
                    {new Date(msg.timestamp).toLocaleTimeString()}
                  </span>
                  <p className="text-gray-300">{msg.text}</p>
                </div>
              )}
            </div>
          ))}
          <div ref={messagesEndRef} />
        </div>

        {typingUser && (
          <p className="px-4 text-sm text-gray-500">{typingUser} が入力中...</p>
        )}

        <form onSubmit={handleSubmit} className="p-4 border-t border-gray-800 flex gap-2">
          <input
            value={input}
            onChange={handleInput}
            placeholder="メッセージを入力..."
            className="flex-1 px-4 py-2 rounded-lg bg-gray-800 border border-gray-700"
          />
          <button type="submit" className="px-6 py-2 bg-indigo-500 text-white rounded-lg">
            送信
          </button>
        </form>
      </div>
    </div>
  );
}

まとめ

  • WebSocket は双方向通信を実現し、リアルタイムアプリに最適
  • Socket.IO はWebSocketを簡単に扱えるライブラリ(自動再接続等)
  • サーバーで接続管理、メッセージのブロードキャストを行う
  • React の useEffect でイベントリスナーを管理し、クリーンアップを忘れない
  • タイピングインジケーターはタイムアウトで自動停止する