第4回

useEffectのクリーンアップ:メモリリークを防ぐ正しい実装

useEffectのクリーンアップ関数を解説します。タイマー・イベントリスナー・非同期処理のキャンセルなど、アンマウント時に必要なクリーンアップの実装方法をメモリリーク防止の観点から学べます。

·13分で読める
たける
たける カウントダウンタイマーを実装したんですが、別のページに移動してもカウントが続いてて、コンソールに「Can't perform a React state update on an unmounted component」ってエラーが出てます。
りこ
りこ クリーンアップを書いていないから。setIntervalを開始したなら、コンポーネントが消えるときにclearIntervalする。useEffectは「副作用を終わらせる方法」も一緒に書く。

クリーンアップとは

useEffect で開始した処理(タイマー・イベントリスナー・WebSocket接続など)は、コンポーネントがアンマウントされたあとも動き続けることがあります。これがメモリリークの原因です。

クリーンアップ関数を返すことで、Reactはコンポーネントのアンマウント時(または次の副作用実行前)にそれを呼び出します。

useEffect(() => {
  // 副作用を開始
  const subscription = someAPI.subscribe();

  // クリーンアップ関数を返す
  return () => {
    subscription.unsubscribe();  // アンマウント時に実行
  };
}, []);

タイマーのクリーンアップ

setIntervalsetTimeout は使い終わったら必ず解除します。

function Countdown({ seconds }: { seconds: number }) {
  const [remaining, setRemaining] = useState(seconds);

  useEffect(() => {
    if (remaining <= 0) return;

    const timerId = setInterval(() => {
      setRemaining((prev) => prev - 1);
    }, 1000);

    // クリーンアップ:アンマウント時にタイマーを止める
    return () => clearInterval(timerId);
  }, [remaining]);

  if (remaining <= 0) return <p>時間切れ!</p>;
  return <p>残り {remaining} 秒</p>;
}
function DelayedMessage({ delay }: { delay: number }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const timerId = setTimeout(() => {
      setMessage('表示されました!');
    }, delay);

    return () => clearTimeout(timerId);  // アンマウント時にキャンセル
  }, [delay]);

  return <p>{message}</p>;
}

イベントリスナーのクリーンアップ

function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handleScroll = () => setScrollY(window.scrollY);

    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll);  // クリーンアップ
    };
  }, []);

  return <div className="scroll-indicator">{scrollY}px</div>;
}
function KeyboardShortcut({ onSave }: { onSave: () => void }) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 's') {
        e.preventDefault();
        onSave();
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [onSave]);

  return null;
}

非同期処理のキャンセル

コンポーネントがアンマウントされた後でstateを更新しようとすると、Reactが警告を出します(React 18以降は警告のみですが、設計上は防ぐべきです)。

フラグを使う方法

function UserCard({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    let isMounted = true;  // マウント中かどうかのフラグ

    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        if (isMounted) {  // マウント中のみ更新
          setUser(data);
        }
      });

    return () => {
      isMounted = false;  // アンマウント時にフラグをfalseに
    };
  }, [userId]);

  return user ? <div>{user.name}</div> : <div>読み込み中...</div>;
}

AbortControllerを使う方法(推奨)

より現代的な方法は AbortController でfetchリクエスト自体をキャンセルします。

function ArticleViewer({ articleId }: { articleId: number }) {
  const [article, setArticle] = useState<Article | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();  // キャンセル用コントローラー

    async function fetchArticle() {
      try {
        setIsLoading(true);
        const response = await fetch(`/api/articles/${articleId}`, {
          signal: controller.signal,  // AbortSignalを渡す
        });
        const data = await response.json();
        setArticle(data);
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          return;  // キャンセルによるエラーは無視
        }
        console.error(err);
      } finally {
        setIsLoading(false);
      }
    }

    fetchArticle();

    return () => {
      controller.abort();  // クリーンアップ:リクエストをキャンセル
    };
  }, [articleId]);

  if (isLoading) return <div>読み込み中...</div>;
  return article ? <article>{article.title}</article> : null;
}

WebSocket接続のクリーンアップ

function LiveChat({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/rooms/${roomId}`);

    ws.onmessage = (event) => {
      setMessages((prev) => [...prev, event.data]);
    };

    return () => {
      ws.close();  // アンマウント時に接続を閉じる
    };
  }, [roomId]);

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

クリーンアップが実行されるタイミング

1. コンポーネントがマウントされる
   → useEffectのコールバックが実行される

2. 依存配列の値が変わる(再実行前)
   → 前回のクリーンアップが実行される
   → 新しいコールバックが実行される

3. コンポーネントがアンマウントされる
   → クリーンアップが実行される
たける
たける AbortControllerってfetch専用ですか? APIを叩いてる途中でページを離れたとき、そのレスポンスを捨てたいケースが多くて。
りこ
りこ fetchのキャンセルに使う。コンポーネントがアンマウントされた後にstateを更新しようとするとReactがエラーを出す。AbortControllerでfetchをキャンセルすることでそれを防ぐ。
翔太
翔太 インフラ的に補足すると、クライアントがリクエストを切断してもサーバー側の処理は続くことがある。AbortControllerはブラウザ・ネットワーク間のキャンセルで、サーバーまで止めるには別途設計が必要。

まとめ

  • useEffect から関数を返すとクリーンアップとして実行される
  • setInterval / setTimeoutclearInterval / clearTimeout で解除する
  • addEventListenerremoveEventListener で解除する
  • fetch のキャンセルには AbortController を使う
  • クリーンアップを怠るとメモリリークや予期しない状態更新の原因になる

次の第5回では、コンポーネントツリー全体で値を共有する useContext を学びます。