Skip to content

WebSocket通信

WebSocketは、クライアントとサーバー間の双方向通信を実現するプロトコルです。Reactアプリケーションでリアルタイム通信を実装する際に使用します。

問題のあるHTTPポーリングの例:

// 問題のある実装: 定期的にサーバーにリクエスト
useEffect(() => {
const interval = setInterval(() => {
fetch('/api/messages')
.then(response => response.json())
.then(data => setMessages(data));
}, 1000); // 1秒ごとにポーリング
return () => clearInterval(interval);
}, []);
// 問題点:
// - 不要なリクエストが多い(サーバー負荷)
// - リアルタイム性が低い(最大1秒の遅延)
// - ネットワーク帯域の無駄
// - バッテリー消費が大きい(モバイル)

WebSocketの解決:

// 1回の接続で双方向通信
const socket = new WebSocket('ws://localhost:3000');
socket.onmessage = (event) => {
setMessages(JSON.parse(event.data));
};
// メリット:
// - リアルタイム通信(低遅延)
// - サーバーからクライアントへのプッシュが可能
// - ネットワーク効率が良い
// - 接続を維持するため、オーバーヘッドが少ない

WebSocketが必要な「4つの決定的な場面」

Section titled “WebSocketが必要な「4つの決定的な場面」”

WebSocketは、特定の場面で決定的な優位性を発揮します。以下の4つの場面では、WebSocketの使用を強く推奨します。

1. 「いつ来るかわからない」を待つとき(通知・チャット)

Section titled “1. 「いつ来るかわからない」を待つとき(通知・チャット)”

特徴: サーバー側でイベントが発生するタイミングが予測不可能で、クライアントが「待ち」の状態になる。

具体例:

  • 通知: メルカリの購入通知、Uber Eatsの「料理が到着しました」通知
  • チャット: Slack、Discord、LINEなどのメッセージングアプリ
  • イベント通知: GitHubのプルリクエスト通知、Twitterのリプライ通知

実装例:

components/NotificationCenter.tsx
import { useEffect, useState } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
interface Notification {
id: string;
type: 'purchase' | 'delivery' | 'message';
title: string;
message: string;
timestamp: number;
}
export function NotificationCenter() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const { lastMessage } = useWebSocket<Notification>({
url: 'wss://api.example.com/notifications',
reconnect: true,
});
useEffect(() => {
if (lastMessage) {
// サーバーから通知が来たら即座に表示
setNotifications((prev) => [lastMessage, ...prev]);
// ブラウザ通知も表示(オプション)
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(lastMessage.title, {
body: lastMessage.message,
icon: '/notification-icon.png',
});
}
}
}, [lastMessage]);
return (
<div className="notification-center">
<h2>通知</h2>
{notifications.map((notification) => (
<div key={notification.id} className="notification">
<h3>{notification.title}</h3>
<p>{notification.message}</p>
<span>{new Date(notification.timestamp).toLocaleString()}</span>
</div>
))}
</div>
);
}

なぜWebSocketが必要か:

  • HTTPポーリングでは、通知が来るまで無駄なリクエストが発生する
  • WebSocketなら、通知が来た瞬間に即座にクライアントに届く
  • バッテリー消費とネットワーク帯域を節約できる

2. 「超リアルタイム」な同期が必要なとき(共同編集・ゲーム)

Section titled “2. 「超リアルタイム」な同期が必要なとき(共同編集・ゲーム)”

特徴: 複数のユーザーが同時に操作し、その操作を即座に他のユーザーに反映させる必要がある。

具体例:

  • 共同編集: Google Docs、Notion、Figmaなどのリアルタイム共同編集
  • ゲーム: マルチプレイヤーゲーム、リアルタイム対戦
  • ホワイトボード: Miro、Muralなどのオンラインホワイトボード

実装例:

components/CollaborativeEditor.tsx
import { useEffect, useState, useCallback } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
interface CursorPosition {
userId: string;
userName: string;
x: number;
y: number;
}
interface TextChange {
userId: string;
position: number;
text: string;
timestamp: number;
}
export function CollaborativeEditor() {
const [content, setContent] = useState('');
const [cursors, setCursors] = useState<CursorPosition[]>([]);
const [isTyping, setIsTyping] = useState<Set<string>>(new Set());
const { sendMessage, lastMessage } = useWebSocket<CursorPosition | TextChange>({
url: 'wss://api.example.com/editor',
reconnect: true,
});
// カーソル位置の更新
const handleMouseMove = useCallback((e: React.MouseEvent) => {
sendMessage({
userId: 'current-user-id',
userName: 'Current User',
x: e.clientX,
y: e.clientY,
});
}, [sendMessage]);
// テキスト変更の送信
const handleTextChange = useCallback((newText: string) => {
setContent(newText);
sendMessage({
userId: 'current-user-id',
position: newText.length,
text: newText,
timestamp: Date.now(),
});
}, [sendMessage]);
// サーバーからの更新を受信
useEffect(() => {
if (!lastMessage) return;
if ('x' in lastMessage) {
// カーソル位置の更新
setCursors((prev) => {
const filtered = prev.filter((c) => c.userId !== lastMessage.userId);
return [...filtered, lastMessage as CursorPosition];
});
} else if ('text' in lastMessage) {
// テキスト変更の反映
const change = lastMessage as TextChange;
if (change.userId !== 'current-user-id') {
setContent(change.text);
setIsTyping((prev) => new Set(prev).add(change.userId));
setTimeout(() => {
setIsTyping((prev) => {
const next = new Set(prev);
next.delete(change.userId);
return next;
});
}, 1000);
}
}
}, [lastMessage]);
return (
<div className="collaborative-editor" onMouseMove={handleMouseMove}>
<div className="editor-header">
<h2>共同編集</h2>
<div className="cursors">
{cursors.map((cursor) => (
<div
key={cursor.userId}
className="cursor"
style={{ left: cursor.x, top: cursor.y }}
>
{cursor.userName}
</div>
))}
</div>
{isTyping.size > 0 && (
<div className="typing-indicator">
{Array.from(isTyping).join(', ')}が入力中...
</div>
)}
</div>
<textarea
value={content}
onChange={(e) => handleTextChange(e.target.value)}
placeholder="共同編集エディタ..."
/>
</div>
);
}

なぜWebSocketが必要か:

  • 数ミリ秒の遅延がユーザー体験に大きく影響する
  • HTTPポーリングでは、リアルタイム性を実現できない
  • 複数のユーザーの操作を即座に同期する必要がある

3. 「刻一刻と変化する数値」を見せるとき(金融・ライブ)

Section titled “3. 「刻一刻と変化する数値」を見せるとき(金融・ライブ)”

特徴: 数値が頻繁に更新され、その更新を即座にユーザーに表示する必要がある。

具体例:

  • 金融: 株価、為替レート、暗号通貨の価格
  • ライブ配信: YouTube Live、Twitchの視聴者数、いいね数
  • ダッシュボード: リアルタイムメトリクス、モニタリング

実装例:

components/StockPriceTicker.tsx
import { useEffect, useState } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
interface StockPrice {
symbol: string;
price: number;
change: number;
changePercent: number;
timestamp: number;
}
export function StockPriceTicker() {
const [prices, setPrices] = useState<Map<string, StockPrice>>(new Map());
const { lastMessage } = useWebSocket<StockPrice>({
url: 'wss://api.example.com/stock-prices',
reconnect: true,
});
useEffect(() => {
if (lastMessage) {
// 価格が更新されたら即座に反映
setPrices((prev) => {
const next = new Map(prev);
next.set(lastMessage.symbol, lastMessage);
return next;
});
}
}, [lastMessage]);
return (
<div className="stock-ticker">
<h2>リアルタイム株価</h2>
<div className="stock-list">
{Array.from(prices.values()).map((stock) => (
<div key={stock.symbol} className="stock-item">
<div className="stock-symbol">{stock.symbol}</div>
<div className="stock-price">${stock.price.toFixed(2)}</div>
<div
className={`stock-change ${
stock.change >= 0 ? 'positive' : 'negative'
}`}
>
{stock.change >= 0 ? '+' : ''}
{stock.change.toFixed(2)} ({stock.changePercent.toFixed(2)}%)
</div>
<div className="stock-time">
{new Date(stock.timestamp).toLocaleTimeString()}
</div>
</div>
))}
</div>
</div>
);
}

なぜWebSocketが必要か:

  • 価格が1秒間に複数回更新される可能性がある
  • HTTPポーリングでは、更新を見逃す可能性がある
  • ユーザーは最新の情報を即座に知りたい

4. サーバー側で「処理が終わるまで時間がかかる」とき

Section titled “4. サーバー側で「処理が終わるまで時間がかかる」とき”

特徴: サーバー側で長時間処理が実行され、その進捗をリアルタイムでクライアントに通知する必要がある。

具体例:

  • ファイル処理: 動画のエンコード、画像のリサイズ、データのインポート/エクスポート
  • 機械学習: モデルの学習、推論処理
  • ビルド・デプロイ: CI/CDパイプラインの進捗、ビルドログのストリーミング

実装例:

components/FileProcessor.tsx
import { useEffect, useState } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
interface ProcessingStatus {
jobId: string;
status: 'queued' | 'processing' | 'completed' | 'failed';
progress: number; // 0-100
message: string;
resultUrl?: string;
error?: string;
}
export function FileProcessor() {
const [status, setStatus] = useState<ProcessingStatus | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const { sendMessage, lastMessage } = useWebSocket<ProcessingStatus>({
url: 'wss://api.example.com/processing',
reconnect: true,
});
useEffect(() => {
if (lastMessage) {
// サーバーから進捗が送られてきたら即座に更新
setStatus(lastMessage);
}
}, [lastMessage]);
const handleFileUpload = async (file: File) => {
// ファイルをアップロード
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const { jobId: newJobId } = await response.json();
setJobId(newJobId);
// WebSocketで進捗を監視開始
sendMessage({ type: 'subscribe', jobId: newJobId });
};
return (
<div className="file-processor">
<h2>ファイル処理</h2>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
}}
/>
{status && (
<div className="processing-status">
<div className="status-header">
<h3>処理状況</h3>
<span className={`status-badge status-${status.status}`}>
{status.status === 'queued' && '待機中'}
{status.status === 'processing' && '処理中'}
{status.status === 'completed' && '完了'}
{status.status === 'failed' && '失敗'}
</span>
</div>
{status.status === 'processing' && (
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${status.progress}%` }}
/>
<span className="progress-text">{status.progress}%</span>
</div>
)}
<div className="status-message">{status.message}</div>
{status.status === 'completed' && status.resultUrl && (
<a href={status.resultUrl} download>
処理済みファイルをダウンロード
</a>
)}
{status.status === 'failed' && status.error && (
<div className="error-message">{status.error}</div>
)}
</div>
)}
</div>
);
}

なぜWebSocketが必要か:

  • HTTPポーリングでは、進捗を取得するために頻繁にリクエストが必要
  • 長時間処理の場合、ポーリングの回数が膨大になる
  • WebSocketなら、サーバー側で進捗が更新された瞬間にクライアントに通知される
  • サーバー負荷とネットワーク帯域を大幅に削減できる
場面特徴具体例WebSocketの必要性
1. 通知・チャットいつ来るかわからないイベントを待つメルカリの購入通知、Uber Eatsの到着通知⭐⭐⭐ 必須
2. 共同編集・ゲーム超リアルタイムな同期が必要Google Docs、マルチプレイヤーゲーム⭐⭐⭐ 必須
3. 金融・ライブ刻一刻と変化する数値株価、為替レート、ライブ配信の視聴者数⭐⭐⭐ 必須
4. 長時間処理サーバー側の処理進捗を監視動画エンコード、データインポート⭐⭐ 推奨

これらの場面では、HTTPポーリングやSSE(Server-Sent Events)では不十分で、WebSocketの双方向通信が必須です。

カスタムフックの実装:

hooks/useWebSocket.ts
import { useEffect, useRef, useState, useCallback } from 'react';
interface UseWebSocketOptions {
url: string;
reconnect?: boolean;
reconnectInterval?: number;
onOpen?: () => void;
onClose?: () => void;
onError?: (error: Event) => void;
}
export function useWebSocket<T = any>(options: UseWebSocketOptions) {
const {
url,
reconnect = true,
reconnectInterval = 3000,
onOpen,
onClose,
onError,
} = options;
const [socket, setSocket] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<T | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const socketRef = useRef<WebSocket | null>(null);
const connect = useCallback(() => {
try {
const ws = new WebSocket(url);
socketRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
setSocket(ws);
onOpen?.();
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as T;
setLastMessage(data);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onclose = () => {
setIsConnected(false);
setSocket(null);
onClose?.();
// 自動再接続
if (reconnect) {
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, reconnectInterval);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
onError?.(error);
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
}
}, [url, reconnect, reconnectInterval, onOpen, onClose, onError]);
const sendMessage = useCallback((message: any) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify(message));
} else {
console.warn('WebSocket is not connected');
}
}, []);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
socketRef.current?.close();
setIsConnected(false);
setSocket(null);
}, []);
useEffect(() => {
connect();
return () => {
disconnect();
};
}, [connect, disconnect]);
return {
socket,
isConnected,
lastMessage,
sendMessage,
disconnect,
reconnect: connect,
};
}

チャットアプリケーションの実装:

components/ChatApp.tsx
import React, { useState, useRef, useEffect } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
interface Message {
id: string;
user: string;
text: string;
timestamp: number;
}
export function ChatApp() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const { isConnected, lastMessage, sendMessage } = useWebSocket<Message>({
url: 'ws://localhost:3000/chat',
reconnect: true,
reconnectInterval: 3000,
onOpen: () => {
console.log('WebSocket connected');
},
onClose: () => {
console.log('WebSocket disconnected');
},
onError: (error) => {
console.error('WebSocket error:', error);
},
});
// メッセージを受信したら追加
useEffect(() => {
if (lastMessage) {
setMessages((prev) => [...prev, lastMessage]);
}
}, [lastMessage]);
// メッセージリストを自動スクロール
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = () => {
if (input.trim() && isConnected) {
sendMessage({
user: 'CurrentUser',
text: input,
timestamp: Date.now(),
});
setInput('');
}
};
return (
<div className="chat-app">
<div className="chat-header">
<h2>チャット</h2>
<div className={`status ${isConnected ? 'connected' : 'disconnected'}`}>
{isConnected ? '接続中' : '切断中'}
</div>
</div>
<div className="messages">
{messages.map((message) => (
<div key={message.id} className="message">
<div className="message-user">{message.user}</div>
<div className="message-text">{message.text}</div>
<div className="message-time">
{new Date(message.timestamp).toLocaleTimeString()}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="chat-input">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSend();
}
}}
placeholder="メッセージを入力..."
disabled={!isConnected}
/>
<button onClick={handleSend} disabled={!isConnected || !input.trim()}>
送信
</button>
</div>
</div>
);
}

Socket.ioクライアントの実装:

hooks/useSocketIO.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
interface UseSocketIOOptions {
url: string;
options?: {
autoConnect?: boolean;
reconnection?: boolean;
reconnectionDelay?: number;
reconnectionAttempts?: number;
};
}
export function useSocketIO(options: UseSocketIOOptions) {
const { url, options: socketOptions } = options;
const socketRef = useRef<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<any>(null);
useEffect(() => {
// Socket.ioクライアントを作成
const socket = io(url, {
autoConnect: socketOptions?.autoConnect ?? true,
reconnection: socketOptions?.reconnection ?? true,
reconnectionDelay: socketOptions?.reconnectionDelay ?? 1000,
reconnectionAttempts: socketOptions?.reconnectionAttempts ?? Infinity,
});
socketRef.current = socket;
socket.on('connect', () => {
setIsConnected(true);
console.log('Socket.io connected:', socket.id);
});
socket.on('disconnect', () => {
setIsConnected(false);
console.log('Socket.io disconnected');
});
socket.on('message', (data) => {
setLastMessage(data);
});
socket.on('error', (error) => {
console.error('Socket.io error:', error);
});
return () => {
socket.disconnect();
};
}, [url, socketOptions]);
const emit = useCallback((event: string, data?: any) => {
if (socketRef.current?.connected) {
socketRef.current.emit(event, data);
} else {
console.warn('Socket.io is not connected');
}
}, []);
const on = useCallback((event: string, callback: (data: any) => void) => {
socketRef.current?.on(event, callback);
}, []);
const off = useCallback((event: string, callback?: (data: any) => void) => {
socketRef.current?.off(event, callback);
}, []);
return {
socket: socketRef.current,
isConnected,
lastMessage,
emit,
on,
off,
};
}

Socket.ioの使用例:

components/RealtimeNotifications.tsx
import React, { useEffect, useState } from 'react';
import { useSocketIO } from '../hooks/useSocketIO';
interface Notification {
id: string;
type: 'info' | 'success' | 'warning' | 'error';
message: string;
timestamp: number;
}
export function RealtimeNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const { isConnected, emit, on, off } = useSocketIO({
url: 'http://localhost:3000',
options: {
autoConnect: true,
reconnection: true,
},
});
useEffect(() => {
// 通知イベントをリッスン
const handleNotification = (notification: Notification) => {
setNotifications((prev) => [notification, ...prev]);
// 5秒後に自動的に削除
setTimeout(() => {
setNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
}, 5000);
};
on('notification', handleNotification);
// クリーンアップ
return () => {
off('notification', handleNotification);
};
}, [on, off]);
const handleMarkAsRead = (id: string) => {
emit('markAsRead', { id });
setNotifications((prev) => prev.filter((n) => n.id !== id));
};
return (
<div className="notifications">
<div className="notifications-header">
<h3>通知</h3>
<div className={`status ${isConnected ? 'connected' : 'disconnected'}`}>
{isConnected ? '接続中' : '切断中'}
</div>
</div>
<div className="notifications-list">
{notifications.map((notification) => (
<div
key={notification.id}
className={`notification notification-${notification.type}`}
>
<div className="notification-message">{notification.message}</div>
<div className="notification-time">
{new Date(notification.timestamp).toLocaleTimeString()}
</div>
<button onClick={() => handleMarkAsRead(notification.id)}>
閉じる
</button>
</div>
))}
</div>
</div>
);
}

接続管理の実装:

hooks/useWebSocketManager.ts
import { useCallback, useRef } from 'react';
export function useWebSocketManager() {
const connectionsRef = useRef<Map<string, WebSocket>>(new Map());
const connect = useCallback((id: string, url: string) => {
// 既存の接続を閉じる
const existing = connectionsRef.current.get(id);
if (existing) {
existing.close();
}
// 新しい接続を作成
const ws = new WebSocket(url);
connectionsRef.current.set(id, ws);
return ws;
}, []);
const disconnect = useCallback((id: string) => {
const ws = connectionsRef.current.get(id);
if (ws) {
ws.close();
connectionsRef.current.delete(id);
}
}, []);
const disconnectAll = useCallback(() => {
connectionsRef.current.forEach((ws) => ws.close());
connectionsRef.current.clear();
}, []);
return {
connect,
disconnect,
disconnectAll,
};
}

2. エラーハンドリングとリトライ

Section titled “2. エラーハンドリングとリトライ”

エラーハンドリングとリトライの実装:

hooks/useWebSocketWithRetry.ts
import { useEffect, useRef, useState, useCallback } from 'react';
interface RetryOptions {
maxRetries?: number;
retryDelay?: number;
exponentialBackoff?: boolean;
}
export function useWebSocketWithRetry<T = any>(
url: string,
retryOptions: RetryOptions = {}
) {
const {
maxRetries = 5,
retryDelay = 1000,
exponentialBackoff = true,
} = retryOptions;
const [socket, setSocket] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<T | null>(null);
const retryCountRef = useRef(0);
const retryTimeoutRef = useRef<NodeJS.Timeout>();
const connect = useCallback(() => {
try {
const ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
setSocket(ws);
retryCountRef.current = 0; // リトライカウントをリセット
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as T;
setLastMessage(data);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onclose = () => {
setIsConnected(false);
setSocket(null);
// リトライ
if (retryCountRef.current < maxRetries) {
const delay = exponentialBackoff
? retryDelay * Math.pow(2, retryCountRef.current)
: retryDelay;
retryTimeoutRef.current = setTimeout(() => {
retryCountRef.current++;
connect();
}, delay);
} else {
console.error('Max retries reached');
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
}
}, [url, maxRetries, retryDelay, exponentialBackoff]);
useEffect(() => {
connect();
return () => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
}
socket?.close();
};
}, [connect, socket]);
return {
socket,
isConnected,
lastMessage,
};
}

ReactでのWebSocket通信のポイント:

  • カスタムフック: useWebSocketでWebSocket接続を管理
  • 自動再接続: 接続が切断された場合の自動再接続機能
  • Socket.io: Socket.ioを使用したより高度な実装
  • 接続管理: 複数のWebSocket接続の管理
  • エラーハンドリング: エラーハンドリングとリトライロジック

適切なWebSocket実装により、リアルタイムな双方向通信を実現できます。