実践プロジェクト レッスン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 でイベントリスナーを管理し、クリーンアップを忘れない
- タイピングインジケーターはタイムアウトで自動停止する