React+WebSocket でリアルタイム通信

React

はじめに

近年のWebアプリでは、ユーザー同士が即時に情報をやり取りできる「リアルタイム通信」が欠かせません。
たとえば、チャットアプリでのメッセージ送受信、株価や暗号資産の価格表示、通知システムなど、最新の状態をページの再読み込みなしで反映する仕組みがこれにあたります。

通常のHTTP通信は「リクエストを送ってレスポンスを受け取る」一方向の流れですが、WebSocketを使うとサーバーとクライアントの間で常に接続を維持し、双方向のリアルタイム通信が可能になります。
これにより、サーバー側のイベントをすぐにユーザーへ届けたり、複数のユーザー間で即座にデータを共有したりできます。

本記事では、このWebSocketを使ってReactアプリからリアルタイム通信を実装し、シンプルなチャットアプリを作成します。
ページを再読み込みせずにメッセージが反映される動きを通して、リアルタイム通信の仕組みを体験していきましょう。

実装環境

今回の記事では、以下の環境でリアルタイムチャットアプリを作成します。

  • Node.js:v23.0.0
  • React:v9.0.0
  • OS:macOS(M1チップ搭載)

基本的なコマンドは他のOSでも共通して動作しますが、Windows環境の場合は一部のコマンドをPowerShell用に読み替えて実行してください。

WebSocket の基本

WebSocketは、クライアント(ブラウザ)とサーバーの間で常時接続を維持しながら、双方向にデータをやり取りできる仕組みです。
通常のHTTP通信では、クライアントがリクエストを送って初めてサーバーが応答しますが、WebSocketでは接続を一度確立すると、どちらからでも自由にメッセージを送信できます。

通信の流れ

  1. 接続(Connect):クライアントがサーバーへ接続要求を送信
  2. メッセージ送受信(Send / Receive):接続が確立した状態で双方向通信
  3. 切断(Close):通信を終了し、接続を閉じる

主なイベント

イベント名説明
open接続が確立したときに発火します。初回の通信準備が完了した合図です。
messageサーバーからメッセージを受け取ったときに発火します。リアルタイム更新の要となるイベントです。
close接続が切断されたときに発火します。再接続処理などで利用します。
error通信エラーが発生したときに発火します。接続先のURL誤りなどを検知できます。

基本的な使い方

以下はブラウザでWebSocketを利用する最もシンプルな例です。

// サーバーへ接続
const socket = new WebSocket("ws://localhost:8080");

// 接続完了時
socket.addEventListener("open", () => {
  console.log("接続しました");
  socket.send("こんにちは、サーバー!");
});

// メッセージ受信時
socket.addEventListener("message", (event) => {
  console.log("受信:", event.data);
});

// 接続終了時
socket.addEventListener("close", () => {
  console.log("接続を終了しました");
});

// エラー発生時
socket.addEventListener("error", (err) => {
  console.error("エラー:", err);
});

このように、WebSocketはHTTPと異なり「つながりっぱなし」の通信が可能です。
Reactと組み合わせることで、ページを更新せずともリアルタイムにデータを反映できるアプリを簡単に実現できます。

ReactでWebSocketを使う

Reactでは、useEffectuseState を組み合わせることで、WebSocket通信を簡潔に実装できます。
ここでは、サーバーとの接続・受信処理・切断までの基本的な流れを解説します。

実装の流れ

  1. useEffect でコンポーネントのマウント時にWebSocketへ接続し、アンマウント時に切断する。
  2. useState で受信メッセージを管理し、画面にリアルタイム反映させる。
import { useEffect, useState } from "react";

export default function ChatSample() {
  // 受信したメッセージを配列で管理
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    // WebSocketサーバーへ接続
    const socket = new WebSocket("ws://localhost:8080");

    // サーバーからメッセージを受け取るたびに実行
    socket.onmessage = (event) => {
      setMessages((prev) => [...prev, event.data]);
    };

    // コンポーネントのアンマウント時に接続を閉じる
    return () => {
      socket.close();
    };
  }, []); // 初回マウント時のみ実行

  return (
    <div style={{ padding: 16, fontFamily: "sans-serif" }}>
      <h2>WebSocketメッセージ一覧</h2>
      <ul>
        {messages.map((msg, i) => (
          <li key={i}>{msg}</li>
        ))}
      </ul>
    </div>
  );
}
  1. new WebSocket("ws://localhost:8080")
    WebSocketサーバーへ接続を開始します。
  2. socket.onmessage
    サーバーからメッセージを受信したときに呼び出されます。
    受信内容を setMessages で追加し、UIが自動的に更新されます。
  3. return () => socket.close()
    コンポーネントが破棄されるときに接続をクリーンアップ。
    不要な接続が残るのを防ぎます。

簡易チャットアプリの実装(サーバー構築~React連携まで)

以下の手順で「複数ブラウザ間で即時にメッセージが同期」するところまで一気に確認できます。

プロジェクト構成

realtime-chat/
├─ server/           # WebSocketサーバー(Node + ws)
│  ├─ package.json
│  └─ index.js
└─ client/           # Reactクライアント
   ├─ package.json
   └─ src/
      ├─ main.tsx
      └─ App.tsx

サーバー実装(Node.js + ws)

server/package.json

{
  "name": "ws-chat-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "ws": "^8.18.0"
  }
}

server/index.js

// wsライブラリから WebSocketServer クラスをインポート
import { WebSocketServer } from "ws";

// WebSocketサーバーを待ち受けるポート番号
const PORT = 8080;

// WebSocketサーバーを作成して指定ポートで起動
const wss = new WebSocketServer({ port: PORT });

/**
 * すべての接続中クライアントにデータを送信する関数
 * (exceptで指定されたクライアント以外に送る)
 */
function broadcast(data, except) {
  for (const client of wss.clients) {
    // readyState === 1 は「接続中(OPEN)」を意味する
    if (client !== except && client.readyState === 1) {
      client.send(data);
    }
  }
}

/**
 * クライアントが接続した時に呼ばれるイベント
 * `ws` は接続してきたクライアント1人分のソケット
 */
wss.on("connection", (ws) => {
  console.log("Client connected");

  /**
   * クライアントからメッセージを受信した時に呼ばれるイベント
   * msgBuf は Buffer 型なので、文字列化して扱う
   */
  ws.on("message", (msgBuf) => {
    const text = msgBuf.toString();
    console.log(`Received: ${text}`);

    // 受信したメッセージを全員に配信(送信者も含めてOK)
    broadcast(text, null);
  });

  /**
   * クライアントが切断した時に呼ばれるイベント
   */
  ws.on("close", () => {
    console.log("Client disconnected");
  });

  /**
   * エラー発生時(例:ネットワーク切断など)
   */
  ws.on("error", (err) => {
    console.error("WebSocket error:", err);
  });
});

// サーバー起動ログ
console.log(`WebSocket server listening on ws://localhost:${PORT}`);

起動

cd server
npm i
npm run start

React クライアント実装

client/src/App.tsx

import { useEffect, useMemo, useRef, useState } from "react";

// ===============================
// 型定義:チャットメッセージの1件分
// ===============================
type ChatMsg = {
  id: string;   // 一意のID
  text: string; // メッセージ本文
  ts: number;   // タイムスタンプ(受信時刻)
};

// ===============================
// メインコンポーネント
// ===============================
export default function App() {
  // メッセージ一覧(受信したメッセージを配列で保持)
  const [messages, setMessages] = useState<ChatMsg[]>([]);

  // 入力欄のテキストを保持
  const [input, setInput] = useState("");

  // WebSocketインスタンスを保持するためのRef(再レンダーされても値が消えない)
  const socketRef = useRef<WebSocket | null>(null);

  // ===============================
  // WebSocket 接続処理(初回マウント時のみ実行)
  // ===============================
  useEffect(() => {
    // サーバーに接続
    const ws = new WebSocket("ws://localhost:8080");
    socketRef.current = ws; // Refに保存して他関数でも使えるようにする

    // 接続成功時
    ws.addEventListener("open", () => {
      console.log("connected");
    });

    // サーバーからメッセージを受信したとき
    ws.addEventListener("message", (event) => {
      const text = String(event.data ?? "");
      setMessages((prev) => [
        ...prev,
        { id: crypto.randomUUID(), text, ts: Date.now() }, // 新しいメッセージを配列に追加
      ]);
    });

    // 接続が閉じられたとき
    ws.addEventListener("close", () => {
      console.log("closed");
    });

    // 通信エラー発生時
    ws.addEventListener("error", (err) => {
      console.error("ws error:", err);
    });

    // クリーンアップ(アンマウント時に接続を閉じる)
    return () => {
      ws.close();
    };
  }, []); // ← [] により初回1回だけ実行される

  // ===============================
  // メッセージ送信可能かどうかの判定(入力が空でないとき)
  // ===============================
  const canSend = useMemo(() => input.trim().length > 0, [input]);

  // ===============================
  // 送信処理(ボタンまたはEnterキー押下時)
  // ===============================
  const handleSend = () => {
    const msg = input.trim();
    if (!msg) return;
    socketRef.current?.send(msg); // WebSocket経由でサーバーに送信
    setInput(""); // 入力欄をリセット
  };

  // Enterキーで送信するショートカット
  const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
    if (e.key === "Enter" && canSend) {
      handleSend();
    }
  };

  // ===============================
  // UI部分(メッセージ一覧・入力欄)
  // ===============================
  return (
    <div style={styles.container}>
      <h1 style={styles.title}>リアルタイム チャット</h1>

      {/* チャットメッセージ一覧 */}
      <div style={styles.chatBox}>
        {messages.map((m) => (
          <div key={m.id} style={styles.message}>
            <span>{m.text}</span>
            <time style={styles.time}>
              {new Date(m.ts).toLocaleTimeString()}
            </time>
          </div>
        ))}
      </div>

      {/* 入力欄と送信ボタン */}
      <div style={styles.inputRow}>
        <input
          style={styles.input}
          placeholder="メッセージを入力..."
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleKeyDown}
        />
        <button style={styles.button} onClick={handleSend} disabled={!canSend}>
          送信
        </button>
      </div>

      {/* ヒントメッセージ */}
      <p style={styles.tip}>
        もう一つブラウザ(別ウィンドウ/別タブ)で同じページを開き、どちらかで送信すると
        <b>即座にもう一方にも表示</b>されれば成功です。
      </p>
    </div>
  );
}

// ===============================
// スタイル定義(簡易的なCSS)
// ===============================
const styles: Record<string, React.CSSProperties> = {
  container: { maxWidth: 720, margin: "40px auto", padding: 16, fontFamily: "sans-serif" },
  title: { marginBottom: 16 },
  chatBox: {
    border: "1px solid #ddd",
    borderRadius: 8,
    padding: 12,
    minHeight: 280,
    overflowY: "auto",
    background: "#fafafa",
    marginBottom: 12,
  },
  message: {
    display: "flex",
    alignItems: "baseline",
    justifyContent: "space-between",
    gap: 8,
    padding: "6px 8px",
    background: "white",
    borderRadius: 6,
    border: "1px solid #eee",
    marginBottom: 8,
  },
  time: { color: "#888", fontSize: 12 },
  inputRow: { display: "flex", gap: 8 },
  input: {
    flex: 1,
    padding: "10px 12px",
    borderRadius: 8,
    border: "1px solid #ccc",
    outline: "none",
  },
  button: {
    padding: "10px 16px",
    borderRadius: 8,
    border: "1px solid #ccc",
    background: "#fff",
    cursor: "pointer",
  },
  tip: { color: "#555", marginTop: 8 },
};
処理の流れ
  1. コンポーネントがマウントされる
    • useEffect が実行され、サーバー(ws://localhost:8080)に接続。
    • socketRef に WebSocket インスタンスを保持。
  2. 接続確立 (open イベント)
    • コンソールに "connected" が出力。
  3. メッセージ受信 (message イベント)
    • サーバーから届いたデータを messages に追加。
    • React が再レンダーし、画面にメッセージが表示。
  4. メッセージ送信
    • 入力欄にテキストを入力し「送信」ボタンまたはEnterキー押下。
    • handleSend()socket.send() を実行。
    • メッセージがサーバーへ送られ、他のクライアントにもブロードキャストされる。
  5. 別のブラウザで受信確認
    • 他タブでも同じWebSocketサーバーに接続しているため、
      メッセージが即時に反映される。
  6. コンポーネントがアンマウントされる
    • useEffect の return 部分が呼ばれ、WebSocket接続をクローズ。

client/src/main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

起動

npm run dev

動作確認のポイント

  • ブラウザAの入力欄に文字を入れて 送信ブラウザBにも即時に表示
  • 逆方向(B → A)でも同様に表示される。
Screenshot
  • DevTools の Network → WS タブに、ws://localhost:8080 が接続中で Messages が送受信されているログが見える。

まとめ

本記事では、WebSocketの基本構造とReactでの実装方法を通して、リアルタイム通信の仕組みを実際に体験しました。
次のステップとしては、ユーザー名やルーム機能を追加したり、socket.ioを用いた拡張的な通信管理にも挑戦してみましょう。

コメント

タイトルとURLをコピーしました