T-CREATOR

Zustand で WebSocket データを管理する:リアルタイム連携の構築方法

Zustand で WebSocket データを管理する:リアルタイム連携の構築方法

リアルタイム通信が当たり前となった現代の Web アプリケーション開発において、WebSocket と Zustand の組み合わせは非常に強力な選択肢です。WebSocket によるリアルタイム通信と、Zustand のシンプルな状態管理を組み合わせることで、複雑なリアルタイムアプリケーションを効率的に構築できます。

この記事では、WebSocket データを Zustand で管理する実践的な手法を、基本的な接続から自動再接続まで段階的に解説していきます。チャットアプリケーションやライブダッシュボードなど、リアルタイム機能を必要とするアプリケーション開発に役立つ知識を身につけていただけるでしょう。

背景

リアルタイム通信の必要性

現代の Web アプリケーションでは、ユーザー体験の向上のためにリアルタイム通信が欠かせません。チャットアプリケーション、ライブ配信、協調編集ツール、株価情報の配信など、様々な場面でリアルタイムデータの更新が求められています。

従来の HTTP リクエスト・レスポンス方式では、サーバーからクライアントへの能動的な通信ができないため、ポーリングという手法でデータを定期的に取得する必要がありました。しかし、この方法は無駄な通信が発生し、リアルタイム性にも限界があります。

WebSocket の基本概念

WebSocket は、クライアントとサーバー間で双方向の永続的な通信を可能にするプロトコルです。一度接続が確立されると、どちらからでもデータを送信でき、リアルタイム通信を効率的に実現できます。

WebSocket の主な特徴は以下の通りです:

特徴説明
双方向通信クライアント・サーバー双方からデータ送信が可能
低レイテンシHTTP のオーバーヘッドがなく高速
永続接続一度確立した接続を維持し続ける
イベント駆動メッセージ受信時にイベントが発火

課題

WebSocket データ管理の複雑さ

WebSocket を使ったリアルタイム通信では、以下のような課題が発生します:

接続状態の管理 WebSocket の接続状態(接続中、切断中、エラー状態)を適切に管理し、UI に反映する必要があります。ネットワークの不安定さにより接続が切れることもあるため、状態管理は複雑になりがちです。

非同期データの処理 WebSocket から受信するデータは非同期で到着するため、React の状態更新タイミングとの整合性を保つのが困難です。また、複数のコンポーネントで同じ WebSocket データを使用する場合、データの一貫性を保つ仕組みが必要になります。

メモリリークの防止 WebSocket 接続やイベントリスナーの適切なクリーンアップを行わないと、メモリリークが発生する可能性があります。特に SPA(Single Page Application)では、ページ遷移時の処理が重要です。

状態管理の難しさ

従来の React のuseStateuseReducerだけで WebSocket データを管理しようとすると、以下の問題が発生します:

  • コンポーネント間でのデータ共有が困難
  • プロップドリリングによるコードの複雑化
  • 状態の更新ロジックの分散化
  • テストの困難さ

これらの課題を解決するために、適切な状態管理ライブラリの選択が重要になります。

解決策

Zustand を活用した WebSocket 連携の設計パターン

Zustand は、WebSocket データ管理に最適な特徴を持っています:

シンプルな API Zustand のシンプルなストア作成 API により、WebSocket 関連の状態を直感的に管理できます。ボイラープレートコードが少なく、開発効率が向上します。

柔軟な状態更新 Zustand では、非同期処理を含む複雑な状態更新ロジックをストア内に記述できます。WebSocket のイベントハンドラーから直接ストアを更新することで、一貫性のあるデータ管理が可能です。

TypeScript 対応 TypeScript との親和性が高く、WebSocket メッセージの型安全性を確保できます。これにより、開発時のエラーを早期に発見し、保守性の高いコードを書けます。

基本的な設計パターンとして、以下の構造を採用します:

  • 接続管理: WebSocket 接続の状態とライフサイクル管理
  • メッセージ管理: 送受信メッセージのキューイングと処理
  • エラーハンドリング: 接続エラーや通信エラーの適切な処理
  • 自動再接続: ネットワーク障害時の自動復旧機能

具体例

基本的な WebSocket 接続と Zustand ストアの構築

まず、WebSocket と Zustand を組み合わせた基本的なストアを構築してみましょう。

typescriptimport { create } from 'zustand';

// WebSocketの接続状態を表す型定義
type ConnectionStatus =
  | 'connecting'
  | 'connected'
  | 'disconnected'
  | 'error';

// メッセージの型定義
interface Message {
  id: string;
  content: string;
  timestamp: number;
  userId: string;
}

// WebSocketストアの状態型定義
interface WebSocketStore {
  // 接続状態
  status: ConnectionStatus;
  // WebSocketインスタンス
  socket: WebSocket | null;
  // 受信メッセージ一覧
  messages: Message[];
  // エラー情報
  error: string | null;

  // アクション
  connect: (url: string) => void;
  disconnect: () => void;
  sendMessage: (content: string) => void;
  addMessage: (message: Message) => void;
  setStatus: (status: ConnectionStatus) => void;
  setError: (error: string | null) => void;
}

このコードでは、WebSocket 通信に必要な状態とアクションを型定義しています。接続状態、メッセージ、エラー情報を管理し、接続・切断・メッセージ送信などの操作を定義しています。

次に、実際の Zustand ストアを作成します:

typescriptexport const useWebSocketStore = create<WebSocketStore>(
  (set, get) => ({
    status: 'disconnected',
    socket: null,
    messages: [],
    error: null,

    connect: (url: string) => {
      const { socket: currentSocket } = get();

      // 既存の接続がある場合は切断
      if (currentSocket) {
        currentSocket.close();
      }

      set({ status: 'connecting', error: null });

      try {
        const newSocket = new WebSocket(url);

        // 接続成功時の処理
        newSocket.onopen = () => {
          console.log('WebSocket接続が確立されました');
          set({ status: 'connected', socket: newSocket });
        };

        // メッセージ受信時の処理
        newSocket.onmessage = (event) => {
          try {
            const message: Message = JSON.parse(event.data);
            get().addMessage(message);
          } catch (error) {
            console.error(
              'メッセージの解析に失敗しました:',
              error
            );
          }
        };

        // 接続終了時の処理
        newSocket.onclose = () => {
          console.log('WebSocket接続が終了しました');
          set({ status: 'disconnected', socket: null });
        };

        // エラー発生時の処理
        newSocket.onerror = (error) => {
          console.error('WebSocketエラー:', error);
          set({
            status: 'error',
            error: 'WebSocket接続でエラーが発生しました',
          });
        };
      } catch (error) {
        set({
          status: 'error',
          error: '接続の初期化に失敗しました',
        });
      }
    },

    disconnect: () => {
      const { socket } = get();
      if (socket) {
        socket.close();
      }
      set({ status: 'disconnected', socket: null });
    },

    sendMessage: (content: string) => {
      const { socket, status } = get();

      if (status !== 'connected' || !socket) {
        console.warn('WebSocketが接続されていません');
        return;
      }

      const message = {
        id: Date.now().toString(),
        content,
        timestamp: Date.now(),
        userId: 'current-user', // 実際のアプリでは認証情報から取得
      };

      try {
        socket.send(JSON.stringify(message));
      } catch (error) {
        console.error(
          'メッセージの送信に失敗しました:',
          error
        );
        set({ error: 'メッセージの送信に失敗しました' });
      }
    },

    addMessage: (message: Message) => {
      set((state) => ({
        messages: [...state.messages, message],
      }));
    },

    setStatus: (status: ConnectionStatus) => {
      set({ status });
    },

    setError: (error: string | null) => {
      set({ error });
    },
  })
);

このストアでは、WebSocket の接続・切断・メッセージ送受信の全ての機能を実装しています。各イベントハンドラーでストアの状態を適切に更新することで、リアルタイムデータの管理を実現しています。

接続状態管理とエラーハンドリング

WebSocket 接続の状態を視覚的に表示し、エラーハンドリングを行うコンポーネントを作成しましょう:

typescriptimport React from 'react';
import { useWebSocketStore } from './websocket-store';

export const ConnectionStatus: React.FC = () => {
  const { status, error, connect, disconnect } =
    useWebSocketStore();

  // 接続状態に応じたスタイルを取得
  const getStatusStyle = (status: string) => {
    switch (status) {
      case 'connected':
        return {
          color: '#10b981',
          backgroundColor: '#d1fae5',
        };
      case 'connecting':
        return {
          color: '#f59e0b',
          backgroundColor: '#fef3c7',
        };
      case 'error':
        return {
          color: '#ef4444',
          backgroundColor: '#fee2e2',
        };
      default:
        return {
          color: '#6b7280',
          backgroundColor: '#f3f4f6',
        };
    }
  };

  // 接続状態の表示テキストを取得
  const getStatusText = (status: string) => {
    switch (status) {
      case 'connected':
        return '接続中';
      case 'connecting':
        return '接続中...';
      case 'error':
        return 'エラー';
      default:
        return '未接続';
    }
  };

  return (
    <div
      style={{
        padding: '16px',
        border: '1px solid #e5e7eb',
        borderRadius: '8px',
      }}
    >
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: '12px',
          marginBottom: '12px',
        }}
      >
        <div
          style={{
            ...getStatusStyle(status),
            padding: '4px 12px',
            borderRadius: '16px',
            fontSize: '14px',
            fontWeight: 'bold',
          }}
        >
          {getStatusText(status)}
        </div>

        {status === 'disconnected' && (
          <button
            onClick={() => connect('ws://localhost:8080')}
            style={{
              padding: '8px 16px',
              backgroundColor: '#3b82f6',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            接続
          </button>
        )}

        {status === 'connected' && (
          <button
            onClick={disconnect}
            style={{
              padding: '8px 16px',
              backgroundColor: '#ef4444',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            切断
          </button>
        )}
      </div>

      {error && (
        <div
          style={{
            padding: '12px',
            backgroundColor: '#fee2e2',
            color: '#dc2626',
            borderRadius: '4px',
            fontSize: '14px',
          }}
        >
          エラー: {error}
        </div>
      )}
    </div>
  );
};

このコンポーネントでは、WebSocket の接続状態を視覚的に表示し、接続・切断ボタンを提供しています。エラーが発生した場合は、エラーメッセージも表示されます。

メッセージ送受信の実装

次に、メッセージの送受信を行うチャットコンポーネントを実装します:

typescriptimport React, { useState, useEffect, useRef } from 'react';
import { useWebSocketStore } from './websocket-store';

export const ChatComponent: React.FC = () => {
  const { messages, sendMessage, status } =
    useWebSocketStore();
  const [inputValue, setInputValue] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);

  // 新しいメッセージが追加されたら自動スクロール
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({
      behavior: 'smooth',
    });
  }, [messages]);

  // メッセージ送信処理
  const handleSendMessage = (e: React.FormEvent) => {
    e.preventDefault();

    if (!inputValue.trim()) return;

    sendMessage(inputValue.trim());
    setInputValue('');
  };

  // 送信ボタンの有効/無効状態
  const canSendMessage =
    status === 'connected' && inputValue.trim().length > 0;

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        height: '400px',
        border: '1px solid #e5e7eb',
        borderRadius: '8px',
      }}
    >
      {/* メッセージ表示エリア */}
      <div
        style={{
          flex: 1,
          overflowY: 'auto',
          padding: '16px',
          backgroundColor: '#f9fafb',
        }}
      >
        {messages.length === 0 ? (
          <div
            style={{
              textAlign: 'center',
              color: '#6b7280',
              marginTop: '50px',
            }}
          >
            メッセージがありません
          </div>
        ) : (
          messages.map((message) => (
            <div
              key={message.id}
              style={{
                marginBottom: '12px',
                padding: '12px',
                backgroundColor: 'white',
                borderRadius: '8px',
                boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
              }}
            >
              <div
                style={{
                  fontSize: '14px',
                  fontWeight: 'bold',
                  marginBottom: '4px',
                  color: '#374151',
                }}
              >
                {message.userId}
              </div>
              <div
                style={{
                  fontSize: '16px',
                  color: '#111827',
                  marginBottom: '4px',
                }}
              >
                {message.content}
              </div>
              <div
                style={{
                  fontSize: '12px',
                  color: '#6b7280',
                }}
              >
                {new Date(
                  message.timestamp
                ).toLocaleTimeString()}
              </div>
            </div>
          ))
        )}
        <div ref={messagesEndRef} />
      </div>

      {/* メッセージ入力エリア */}
      <form
        onSubmit={handleSendMessage}
        style={{
          padding: '16px',
          borderTop: '1px solid #e5e7eb',
          backgroundColor: 'white',
        }}
      >
        <div style={{ display: 'flex', gap: '8px' }}>
          <input
            type='text'
            value={inputValue}
            onChange={(e) => setInputValue(e.target.value)}
            placeholder={
              status === 'connected'
                ? 'メッセージを入力してください...'
                : 'WebSocketに接続してください'
            }
            disabled={status !== 'connected'}
            style={{
              flex: 1,
              padding: '12px',
              border: '1px solid #d1d5db',
              borderRadius: '4px',
              fontSize: '16px',
              outline: 'none',
            }}
          />
          <button
            type='submit'
            disabled={!canSendMessage}
            style={{
              padding: '12px 24px',
              backgroundColor: canSendMessage
                ? '#3b82f6'
                : '#9ca3af',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: canSendMessage
                ? 'pointer'
                : 'not-allowed',
              fontSize: '16px',
              fontWeight: 'bold',
            }}
          >
            送信
          </button>
        </div>
      </form>
    </div>
  );
};

このチャットコンポーネントでは、メッセージの表示と送信機能を実装しています。新しいメッセージが追加されると自動的にスクロールし、接続状態に応じて入力フィールドの有効/無効を切り替えています。

自動再接続機能の実装

ネットワークの不安定さに対応するため、自動再接続機能を実装しましょう:

typescript// 自動再接続機能を追加したWebSocketストア
export const useWebSocketStore = create<WebSocketStore>(
  (set, get) => ({
    status: 'disconnected',
    socket: null,
    messages: [],
    error: null,
    // 再接続設定
    reconnectAttempts: 0,
    maxReconnectAttempts: 5,
    reconnectInterval: 1000, // 初期間隔(ミリ秒)
    reconnectTimeoutId: null as NodeJS.Timeout | null,

    connect: (url: string) => {
      const { socket: currentSocket, reconnectTimeoutId } =
        get();

      // 既存の再接続タイマーをクリア
      if (reconnectTimeoutId) {
        clearTimeout(reconnectTimeoutId);
        set({ reconnectTimeoutId: null });
      }

      // 既存の接続がある場合は切断
      if (currentSocket) {
        currentSocket.close();
      }

      set({ status: 'connecting', error: null });

      try {
        const newSocket = new WebSocket(url);

        newSocket.onopen = () => {
          console.log('WebSocket接続が確立されました');
          set({
            status: 'connected',
            socket: newSocket,
            reconnectAttempts: 0, // 接続成功時にリセット
            error: null,
          });
        };

        newSocket.onmessage = (event) => {
          try {
            const message: Message = JSON.parse(event.data);
            get().addMessage(message);
          } catch (error) {
            console.error(
              'メッセージの解析に失敗しました:',
              error
            );
          }
        };

        newSocket.onclose = (event) => {
          console.log(
            'WebSocket接続が終了しました',
            event.code,
            event.reason
          );
          set({ status: 'disconnected', socket: null });

          // 意図的な切断でない場合は自動再接続を試行
          if (event.code !== 1000) {
            // 1000は正常終了
            get().attemptReconnect(url);
          }
        };

        newSocket.onerror = (error) => {
          console.error('WebSocketエラー:', error);
          set({
            status: 'error',
            error: 'WebSocket接続でエラーが発生しました',
          });
        };
      } catch (error) {
        set({
          status: 'error',
          error: '接続の初期化に失敗しました',
        });
      }
    },

    // 自動再接続の試行
    attemptReconnect: (url: string) => {
      const {
        reconnectAttempts,
        maxReconnectAttempts,
        reconnectInterval,
      } = get();

      if (reconnectAttempts >= maxReconnectAttempts) {
        set({
          status: 'error',
          error: `最大再接続試行回数(${maxReconnectAttempts}回)に達しました`,
        });
        return;
      }

      // 指数バックオフで再接続間隔を調整
      const delay =
        reconnectInterval * Math.pow(2, reconnectAttempts);

      console.log(
        `${delay}ms後に再接続を試行します(${
          reconnectAttempts + 1
        }/${maxReconnectAttempts}回目)`
      );

      set({
        status: 'connecting',
        error: `再接続中... (${
          reconnectAttempts + 1
        }/${maxReconnectAttempts})`,
      });

      const timeoutId = setTimeout(() => {
        set({ reconnectAttempts: reconnectAttempts + 1 });
        get().connect(url);
      }, delay);

      set({ reconnectTimeoutId: timeoutId });
    },

    disconnect: () => {
      const { socket, reconnectTimeoutId } = get();

      // 再接続タイマーをクリア
      if (reconnectTimeoutId) {
        clearTimeout(reconnectTimeoutId);
        set({ reconnectTimeoutId: null });
      }

      if (socket) {
        socket.close(1000, '手動切断'); // 正常終了コードで切断
      }

      set({
        status: 'disconnected',
        socket: null,
        reconnectAttempts: 0,
        error: null,
      });
    },

    // その他のメソッドは前回と同じ...
    sendMessage: (content: string) => {
      const { socket, status } = get();

      if (status !== 'connected' || !socket) {
        console.warn('WebSocketが接続されていません');
        return;
      }

      const message = {
        id: Date.now().toString(),
        content,
        timestamp: Date.now(),
        userId: 'current-user',
      };

      try {
        socket.send(JSON.stringify(message));
      } catch (error) {
        console.error(
          'メッセージの送信に失敗しました:',
          error
        );
        set({ error: 'メッセージの送信に失敗しました' });
      }
    },

    addMessage: (message: Message) => {
      set((state) => ({
        messages: [...state.messages, message],
      }));
    },

    setStatus: (status: ConnectionStatus) => {
      set({ status });
    },

    setError: (error: string | null) => {
      set({ error });
    },
  })
);

この自動再接続機能では、指数バックオフアルゴリズムを使用して再接続間隔を調整し、最大試行回数を設定することで無限ループを防いでいます。

最後に、これらの機能を統合したメインコンポーネントを作成します:

typescriptimport React, { useEffect } from 'react';
import { ConnectionStatus } from './ConnectionStatus';
import { ChatComponent } from './ChatComponent';
import { useWebSocketStore } from './websocket-store';

export const WebSocketApp: React.FC = () => {
  const { disconnect } = useWebSocketStore();

  // コンポーネントのアンマウント時にWebSocket接続をクリーンアップ
  useEffect(() => {
    return () => {
      disconnect();
    };
  }, [disconnect]);

  return (
    <div
      style={{
        maxWidth: '800px',
        margin: '0 auto',
        padding: '20px',
        fontFamily: 'Arial, sans-serif',
      }}
    >
      <h1
        style={{
          textAlign: 'center',
          marginBottom: '30px',
          color: '#111827',
        }}
      >
        WebSocket × Zustand チャットアプリ
      </h1>

      <div style={{ marginBottom: '20px' }}>
        <ConnectionStatus />
      </div>

      <ChatComponent />

      <div
        style={{
          marginTop: '20px',
          padding: '16px',
          backgroundColor: '#f3f4f6',
          borderRadius: '8px',
          fontSize: '14px',
          color: '#6b7280',
        }}
      >
        <p>
          <strong>使用方法:</strong>
        </p>
        <ul
          style={{ margin: '8px 0', paddingLeft: '20px' }}
        >
          <li>「接続」ボタンでWebSocketサーバーに接続</li>
          <li>接続後、メッセージを入力して送信</li>
          <li>ネットワーク障害時は自動で再接続を試行</li>
          <li>「切断」ボタンで手動切断が可能</li>
        </ul>
      </div>
    </div>
  );
};

まとめ

WebSocket と Zustand を組み合わせることで、リアルタイム通信を効率的に管理できることがお分かりいただけたでしょう。この記事で紹介した手法のポイントをまとめると以下の通りです。

設計のポイント

  • WebSocket の状態管理を Zustand ストアに集約することで、コンポーネント間でのデータ共有が簡単になります
  • 接続状態、メッセージ、エラー情報を一元管理することで、アプリケーション全体の状態を把握しやすくなります
  • TypeScript を活用することで、メッセージの型安全性を確保し、開発効率を向上させられます

実装のポイント

  • 自動再接続機能により、ネットワークの不安定さに対応できます
  • 指数バックオフアルゴリズムを使用することで、サーバーへの負荷を軽減しながら再接続を試行できます
  • 適切なクリーンアップ処理により、メモリリークを防止できます

運用のポイント

  • エラーハンドリングを充実させることで、ユーザーに分かりやすいフィードバックを提供できます
  • 接続状態の視覚化により、ユーザーは現在の状況を把握しやすくなります
  • メッセージの送信可否を適切に制御することで、エラーの発生を未然に防げます

この基本的な実装をベースに、認証機能、ルーム機能、ファイル送信機能など、より高度な機能を追加していくことができます。WebSocket と Zustand の組み合わせは、スケーラブルなリアルタイムアプリケーション開発の強力な基盤となるでしょう。

リアルタイム通信を必要とするアプリケーション開発において、ぜひこの手法を活用してみてください。シンプルでありながら堅牢な実装により、優れたユーザー体験を提供できるはずです。

関連リンク