Reactの useEffect では、ライフサイクル終了時に呼ばれるクリーンアップ関数(return で返す関数)を記述することができます。しかし、現場でコードをレビューしていると、このクリーンアップ関数に不要な処理や責任範囲外の処理が含まれている例が多く見られます。

本記事では、レビューアーの立場から「useEffectのクリーンアップ関数の構造的な責務」を明確にし、何を記述すべきで何を記述すべきでないか、構造レベルで判断する指針を示します。

useEffectのクリーンアップ関数とは何か

React公式ドキュメントによれば、useEffect に返された関数は「コンポーネントがアンマウントされる際」または「次のEffectが実行される前」に実行される処理です。

この構造は主にリソースの破棄やイベント解除を目的としており、クリーンアップ関数に新たな副作用を記述することは意図されていません。

クリーンアップ関数とは

useEffect(() => { return () => { ... } }, []) という形式で記述される、Effectの終了時に実行される関数。イベントリスナーの解除、タイマーの停止、WebSocketの切断など、リソース解放責任を担う。

クリーンアップ関数が必要な典型例

イベントリスナーの登録・解除

正しい構造
useEffect(() => {
  const handler = () => console.log("resize");
  window.addEventListener("resize", handler);

  return () => {
    window.removeEventListener("resize", handler);
  };
}, []);
コードレビュー
@Reviewer: 登録・解除がセットになっており、責務が明確です。クリーンアップ関数として適切です。

タイマーやintervalの停止処理

タイマーの構造
useEffect(() => {
  const id = setInterval(() => {
    console.log("tick");
  }, 1000);

  return () => clearInterval(id);
}, []);

このように、「登録 → 削除」「開始 → 停止」の対で動く処理は、クリーンアップ関数に責務を持たせるのが構造的に明快です。

クリーンアップ関数に不要な責務を持たせている例

状態更新のような新たな副作用

誤ったクリーンアップ構造
useEffect(() => {
  return () => {
    setFlag(false); // ← 状態更新は副作用
  };
}, []);
コードレビュー
@Reviewer: クリーンアップ関数での状態更新は、設計意図が明確でない限り避けるべきです。特にアンマウント中のstate更新はReact 18のStrictモードで警告対象となる可能性があります。

データ保存や通信処理のような意図不明の責務

通信処理をクリーンアップに書いた例
useEffect(() => {
  return () => {
    fetch("/api/exit", { method: "POST" });  // ← 誰がいつ呼ぶのか曖昧
  };
}, []);

このような構造は、「なぜここでこの副作用を実行するのか」が読者に伝わらず、再利用性・テスト性ともに劣化します。

レビューで見落とされがちなクリーンアップの欠落

WebSocketや外部接続の未解放

クリーンアップ漏れ
useEffect(() => {
  const socket = new WebSocket("wss://...");
  socket.onmessage = handleMessage;
}, []);
コードレビュー
@Reviewer: WebSocketの接続は明示的に `close()` を行わないと接続が残存します。クリーンアップ関数で `socket.close()` を必ず記述すべきです。

イベント系やリソース系のEffectには「常にクリーンアップが必要」と見るのがレビューの基本スタンスです。

複数副作用の混在とクリーンアップの整理指針

1つの useEffect に複数の副作用とクリーンアップが混在していると、責務が見えにくくなります。

クリーンアップの混在例
useEffect(() => {
  const socket = new WebSocket("wss://...");
  const handler = () => console.log("resize");
  window.addEventListener("resize", handler);

  return () => {
    socket.close();
    window.removeEventListener("resize", handler);
  };
}, []);
コードレビュー
@Reviewer: 複数の副作用が1つのEffectに混在しているため、責務の追跡が難しくなります。通信処理とDOM操作は責務が異なるため、Effectを分離すべきです。

可視化:Effectとクリーンアップの責務対応図

UML Diagram

Hookに抽出されたEffectとクリーンアップの構造判断

カスタムHookの中で副作用とクリーンアップが扱われるケースも多くなっています。レビューアーはその責務をHook外の呼び出し元から読み取れるかどうかを重視すべきです。

カスタムHookでの構造例
function useWebSocket(url: string) {
  useEffect(() => {
    const ws = new WebSocket(url);
    return () => ws.close();
  }, [url]);
}
コードレビュー
@Reviewer: `url` の変更でWebSocketの接続が切り替わる構造は明確ですが、利用側が再接続の責任を持つべきかどうかも検討しておくと良いです。

クリーンアップ関数に関するレビュー観点まとめ

useEffect のクリーンアップ関数は、コードの「終了の設計」です。レビューアーは「何が始まり、どう終わるか」を責務単位で見通せる構造を支持し、不要な副作用の混入や抜け漏れを未然に防ぐ役割を担います。