WebSocketやイベント発火をuseEffectにまとめていないか?

リアルタイム性が求められるUIでは、WebSocketやカスタムイベントを用いた状態の更新が多用されます。ReactではそれらをuseEffect内で登録・解除する構造が一般的ですが、すべての副作用処理を一つのEffectに集約する設計は、責務の追跡を困難にし、バグ混入の温床となる場合があります。

本節では、WebSocketやイベント駆動型の処理において、どのように副作用を整理し、useEffectを分割すべきかという判断軸を、レビューアー視点で解説していきます。

WebSocketと他副作用が混在した構造の例

まずは典型的な問題構造を確認します。

useEffectにWebSocketと副作用をまとめた例
export const NotificationPanel = () => {
  const [messages, setMessages] = useState<string[]>([]);
  const [windowWidth, setWindowWidth] = useState(window.innerWidth);

  useEffect(() => {
    const socket = new WebSocket('wss://example.com/socket');

    socket.addEventListener('message', (event) => {
      const msg = event.data;
      setMessages((prev) => [...prev, msg]);
    });

    const handleResize = () => {
      setWindowWidth(window.innerWidth);
    };
    window.addEventListener('resize', handleResize);

    return () => {
      socket.close();
      window.removeEventListener('resize', handleResize);
    };
  }, []);
};
@Reviewer:
異なる副作用(WebSocket通信とウィンドウイベント)が1つのEffectに統合されており、責務の分離が不十分です。各副作用ごとにEffectを分離し、状態との関連性を明確に保つ構成を検討してください。

このコードでは以下のような問題が生じます。

  • WebSocketresizeイベントという異なるドメインの副作用が1つのEffectにまとめられている
  • return内のクリーンアップが副作用ごとに独立していない
  • テストやデバッグの際にどの処理がどの状態に影響するかの追跡が難しい
WebSocketとは

WebSocketは、クライアントとサーバー間で双方向かつ持続的な通信を行うためのプロトコル。HTTPと異なり、常時接続された状態でリアルタイムにデータを受け渡すことができる。

副作用を責務ごとに分離した構造

副作用の責務が明確であれば、Effectも自然と分割できます。

分離された副作用構造
export const NotificationPanel = () => {
  const [messages, setMessages] = useState<string[]>([]);
  const [windowWidth, setWindowWidth] = useState(window.innerWidth);

  useEffect(() => {
    const socket = new WebSocket('wss://example.com/socket');
    const handleMessage = (event: MessageEvent) => {
      setMessages((prev) => [...prev, event.data]);
    };
    socket.addEventListener('message', handleMessage);

    return () => {
      socket.removeEventListener('message', handleMessage);
      socket.close();
    };
  }, []);

  useEffect(() => {
    const handleResize = () => {
      setWindowWidth(window.innerWidth);
    };
    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
};

このように副作用ごとにEffectを分割すると、以下の利点があります。

  • 責務単位の追跡が容易になる
  • バグが発生した際の影響範囲を特定しやすい
  • 副作用ごとの抽出やHook化への導線が明確になる

カスタムHookとしての再構成と責務設計

さらに再利用性や構造明瞭性を高めるためには、副作用を責務ごとにカスタムHook化する設計が有効です。

useWebSocketMessages.ts
export const useWebSocketMessages = (url: string) => {
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    const socket = new WebSocket(url);
    const handleMessage = (event: MessageEvent) => {
      setMessages((prev) => [...prev, event.data]);
    };
    socket.addEventListener('message', handleMessage);

    return () => {
      socket.removeEventListener('message', handleMessage);
      socket.close();
    };
  }, [url]);

  return messages;
};
useWindowResize.ts
export const useWindowResize = () => {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => {
      setWidth(window.innerWidth);
    };
    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return width;
};
NotificationPanel.tsx
export const NotificationPanel = () => {
  const messages = useWebSocketMessages('wss://example.com/socket');
  const width = useWindowResize();

  return (
    <>
      <p>Window width: {width}</p>
      <ul>
        {messages.map((msg, i) => (
          <li key={i}>{msg}</li>
        ))}
      </ul>
    </>
  );
};

副作用ごとに責務が明確に分離され、それぞれのカスタムHookとして再構成されている点が評価できます。Hook名・構造ともに意図が伝わりやすく、読み手に対してロジックの範囲を明確に示せています。

副作用構造の分離

UML Diagram

レビュー観点:副作用処理の責務分離

副作用の設計を確認する際のチェックポイントは以下です。

  • 1つのuseEffect内に複数の目的(通信・UI操作・イベント登録)が混在していないか
  • 副作用ごとに分離可能な構造か(クリーンアップ処理が明確か)
  • カスタムHook化する際に責務と命名が一致しているか
  • それぞれの副作用がUI状態に与える影響が限定的か

WebSocketやイベントリスナーは構造を誤ると、リソースリークや副作用の再発火といった不具合に直結します。レビュー時には、処理の集約度よりも分離と独立性の高さに着目する視点が重要です。