Reactでメモリリークが起きる|クリーンアップの重要性

Reactアプリでメモリリークが発生する原因と対策を詳しく解説。useEffect、タイマー、イベントリスナーの適切なクリーンアップ方法を初心者向けに説明します。

Learning Next 運営
28 分で読めます

みなさん、Reactアプリを長時間使用していてだんだん重くなってきた経験ありませんか?

「最初は軽快に動いていたのに、使い続けるうちに重くなる」「ブラウザのメモリ使用量が増加し続ける」「アプリがフリーズしてしまう」

そんな悩みを抱えていませんか?

これらの問題は、メモリリークが原因の可能性があります。

でも大丈夫です!

この記事では、Reactアプリでメモリリークが発生する原因と、適切なクリーンアップ方法について詳しく解説します。 正しい知識を身につけることで、快適に動作するアプリを作れるようになりますよ。

メモリリークって何?基本を理解しよう

メモリリークとは、使用しなくなったメモリが解放されず、メモリ使用量が増加し続ける現象です。

簡単に言うと、「もう使わないのに、コンピューターがメモリを手放さない」状態です。

メモリリークが起きるとどうなる?

想像してみてください。

部屋で本を読み終わったのに、本棚に戻さずに机の上に積み重ねていくとします。 どんどん積み重なって、最終的には机が使えなくなりますよね。

メモリリークも同じです。 使わなくなったデータがメモリに残り続けて、最終的にはアプリが動かなくなってしまいます。

実際のメモリリークの例

// メモリリークの例
const ProblematicComponent = () => {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // タイマーを設定するが、クリーンアップしない
    const timer = setInterval(() => {
      fetch('/api/data')
        .then(response => response.json())
        .then(data => setData(data));
    }, 1000);
    
    // クリーンアップ関数がない!
    // return () => clearInterval(timer); // これが必要
  }, []);
  
  return <div>{data ? data.message : 'Loading...'}</div>;
};

このコンポーネントでは、タイマーがクリーンアップされずに残り続けます。

コンポーネントが削除されても、タイマーは動き続けるんです。 これがメモリリークの原因になります。

メモリリークが発生する主な原因を知ろう

メモリリークの原因を一つずつ見ていきましょう。

原因がわかれば、対策も立てられますからね。

useEffectのクリーンアップ不足

最も多い原因の一つです。

// 問題のあるコード
const BadComponent = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);
    
    // クリーンアップ関数がない
  }, []);
  
  return <div>Count: {count}</div>;
};

// 正しいコード
const GoodComponent = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);
    
    // クリーンアップ関数を追加
    return () => {
      clearInterval(timer);
    };
  }, []);
  
  return <div>Count: {count}</div>;
};

return () => { clearInterval(timer); }の部分がポイントです。

この関数が、コンポーネントが削除される時に実行されます。 タイマーを適切に停止してくれるんです。

イベントリスナーの未削除

ブラウザのイベントを監視している場合も要注意です。

// 問題のあるコード
const BadEventComponent = () => {
  const [scrollY, setScrollY] = useState(0);
  
  useEffect(() => {
    const handleScroll = () => {
      setScrollY(window.scrollY);
    };
    
    window.addEventListener('scroll', handleScroll);
    
    // イベントリスナーが削除されない
  }, []);
  
  return <div>Scroll: {scrollY}</div>;
};

// 正しいコード
const GoodEventComponent = () => {
  const [scrollY, setScrollY] = useState(0);
  
  useEffect(() => {
    const handleScroll = () => {
      setScrollY(window.scrollY);
    };
    
    window.addEventListener('scroll', handleScroll);
    
    // イベントリスナーを削除
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);
  
  return <div>Scroll: {scrollY}</div>;
};

addEventListenerで追加したイベントは、removeEventListenerで削除する必要があります。

そうしないと、コンポーネントが削除されてもイベントリスナーが残り続けてしまいます。

非同期処理の適切な処理

APIからデータを取得する際も注意が必要です。

// 問題のあるコード
const BadAsyncComponent = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetchData();
  }, []);
  
  const fetchData = async () => {
    try {
      const response = await fetch('/api/data');
      const result = await response.json();
      setData(result); // コンポーネントが削除されても実行される
      setLoading(false);
    } catch (error) {
      console.error('Error:', error);
    }
  };
  
  return loading ? <div>Loading...</div> : <div>{data.message}</div>;
};

// 正しいコード
const GoodAsyncComponent = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    let isMounted = true;
    
    const fetchData = async () => {
      try {
        const response = await fetch('/api/data');
        const result = await response.json();
        
        // マウント状態を確認してから更新
        if (isMounted) {
          setData(result);
          setLoading(false);
        }
      } catch (error) {
        if (isMounted) {
          console.error('Error:', error);
        }
      }
    };
    
    fetchData();
    
    // クリーンアップ
    return () => {
      isMounted = false;
    };
  }, []);
  
  return loading ? <div>Loading...</div> : <div>{data?.message}</div>;
};

isMountedフラグを使って、コンポーネントがまだ存在するかチェックします。

コンポーネントが削除された後にsetStateが実行されるのを防げるんです。 これで「メモリリーク」の警告メッセージも出なくなりますよ。

具体的なクリーンアップ方法をマスターしよう

各種リソースの正しいクリーンアップ方法を学びましょう。

一度覚えてしまえば、どこでも応用できますよ。

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

setIntervalsetTimeoutの両方をカバーします。

const TimerComponent = () => {
  const [seconds, setSeconds] = useState(0);
  
  useEffect(() => {
    // setInterval
    const interval = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);
    
    return () => clearInterval(interval);
  }, []);
  
  useEffect(() => {
    // setTimeout
    const timeout = setTimeout(() => {
      console.log('5秒経過しました');
    }, 5000);
    
    return () => clearTimeout(timeout);
  }, []);
  
  return <div>経過時間: {seconds}秒</div>;
};

setIntervalclearIntervalで停止。 setTimeoutclearTimeoutで停止。

どちらもクリーンアップ関数で実行するのがポイントです。

WebSocketのクリーンアップ

リアルタイム通信でよく使われるWebSocketも適切にクリーンアップしましょう。

const WebSocketComponent = () => {
  const [messages, setMessages] = useState([]);
  const [socket, setSocket] = useState(null);
  
  useEffect(() => {
    const ws = new WebSocket('ws://localhost:3001');
    
    ws.onopen = () => {
      console.log('WebSocket接続が開始されました');
    };
    
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    };
    
    ws.onerror = (error) => {
      console.error('WebSocketエラー:', error);
    };
    
    setSocket(ws);
    
    // WebSocketのクリーンアップ
    return () => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    };
  }, []);
  
  return (
    <div>
      <h3>メッセージ一覧</h3>
      {messages.map((msg, index) => (
        <div key={index}>{msg.text}</div>
      ))}
    </div>
  );
};

WebSocketの状態をチェックしてからclose()を呼び出します。

これで、接続が適切に切断されるんです。

DOM要素の監視

要素のサイズ変更を監視するResizeObserverも正しくクリーンアップしましょう。

const ResizeObserverComponent = () => {
  const [size, setSize] = useState({ width: 0, height: 0 });
  const divRef = useRef(null);
  
  useEffect(() => {
    const element = divRef.current;
    if (!element) return;
    
    const resizeObserver = new ResizeObserver(entries => {
      for (let entry of entries) {
        setSize({
          width: entry.contentRect.width,
          height: entry.contentRect.height
        });
      }
    });
    
    resizeObserver.observe(element);
    
    // ResizeObserverのクリーンアップ
    return () => {
      resizeObserver.disconnect();
    };
  }, []);
  
  return (
    <div ref={divRef} style={{ resize: 'both', overflow: 'auto', border: '1px solid black' }}>
      <p>幅: {size.width}px</p>
      <p>高さ: {size.height}px</p>
    </div>
  );
};

ResizeObserverdisconnect()メソッドで停止できます。

これで、監視も適切に終了されますね。

AbortControllerで非同期処理を安全にしよう

最新のJavaScriptには、非同期処理を中止する便利な機能があります。

const AbortableComponent = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const abortController = new AbortController();
    
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch('/api/data', {
          signal: abortController.signal
        });
        
        if (!response.ok) {
          throw new Error('データの取得に失敗しました');
        }
        
        const result = await response.json();
        setData(result);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };
    
    fetchData();
    
    // リクエストを中止
    return () => {
      abortController.abort();
    };
  }, []);
  
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  
  return (
    <div>
      <h3>データ</h3>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
};

AbortControllerを使うと、fetchリクエストを途中で中止できます。

signalオプションでコントローラーを渡し、クリーンアップ時にabort()を呼ぶだけ。 とても簡単で安全な方法ですね。

カスタムフックでクリーンアップを再利用しよう

よく使うクリーンアップ処理は、カスタムフックにまとめると便利です。

useIntervalフック

// カスタムフック:useInterval
const useInterval = (callback, delay) => {
  const savedCallback = useRef();
  
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);
  
  useEffect(() => {
    const tick = () => {
      savedCallback.current();
    };
    
    if (delay !== null) {
      const id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
};

// 使用例
const ClockComponent = () => {
  const [time, setTime] = useState(new Date());
  
  useInterval(() => {
    setTime(new Date());
  }, 1000);
  
  return <div>{time.toLocaleTimeString()}</div>;
};

useIntervalフックを使えば、タイマーのクリーンアップを考える必要がありません。

フックが自動的に処理してくれるんです。

useEventListenerフック

const useEventListener = (eventName, handler, element = window) => {
  const savedHandler = useRef();
  
  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);
  
  useEffect(() => {
    const eventListener = (event) => savedHandler.current(event);
    
    element.addEventListener(eventName, eventListener);
    
    return () => {
      element.removeEventListener(eventName, eventListener);
    };
  }, [eventName, element]);
};

// 使用例
const WindowSizeComponent = () => {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEventListener('resize', () => {
    setWindowSize({
      width: window.innerWidth,
      height: window.innerHeight
    });
  });
  
  return (
    <div>
      ウィンドウサイズ: {windowSize.width} x {windowSize.height}
    </div>
  );
};

イベントリスナーの追加と削除も、フックが自動的に処理してくれます。

これで、メモリリークの心配をせずにイベントを監視できますね。

メモリリークの検出方法を覚えよう

メモリリークが起きているかどうか、どうやって確認すればいいでしょうか?

React Developer Toolsで確認

// Profilerを使用した監視
const ProfiledComponent = () => {
  return (
    <Profiler id="MyComponent" onRender={onRenderCallback}>
      <MyComponent />
    </Profiler>
  );
};

const onRenderCallback = (id, phase, actualDuration) => {
  console.log('Component:', id, 'Phase:', phase, 'Duration:', actualDuration);
};

React Developer ToolsのProfiler機能を使うと、コンポーネントのレンダリング回数やパフォーマンスを監視できます。

ブラウザの開発者ツールで確認

// メモリ使用量の監視
const MemoryMonitor = () => {
  const [memoryInfo, setMemoryInfo] = useState(null);
  
  useEffect(() => {
    const updateMemoryInfo = () => {
      if (performance.memory) {
        setMemoryInfo({
          used: Math.round(performance.memory.usedJSHeapSize / 1024 / 1024),
          total: Math.round(performance.memory.totalJSHeapSize / 1024 / 1024),
          limit: Math.round(performance.memory.jsHeapSizeLimit / 1024 / 1024)
        });
      }
    };
    
    updateMemoryInfo();
    const interval = setInterval(updateMemoryInfo, 1000);
    
    return () => clearInterval(interval);
  }, []);
  
  return (
    <div>
      {memoryInfo && (
        <div>
          <p>使用メモリ: {memoryInfo.used}MB</p>
          <p>総メモリ: {memoryInfo.total}MB</p>
          <p>制限: {memoryInfo.limit}MB</p>
        </div>
      )}
    </div>
  );
};

performance.memoryを使うと、JavaScriptのメモリ使用量をリアルタイムで監視できます。

使用メモリが増加し続けている場合は、メモリリークの可能性があります。

実践的なメモリリーク対策

実際の開発でよくあるパターンの対策方法をご紹介します。

大きなデータの適切な管理

const LargeDataComponent = () => {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    const loadData = async () => {
      const response = await fetch('/api/large-data');
      const result = await response.json();
      setData(result);
    };
    
    loadData();
    
    // コンポーネントが削除される際にデータをクリア
    return () => {
      setData(null);
    };
  }, []);
  
  return (
    <div>
      {data ? (
        <div>データ件数: {data.length}</div>
      ) : (
        <div>読み込み中...</div>
      )}
    </div>
  );
};

大量のデータを扱う場合は、コンポーネントが削除される時にデータもクリアしましょう。

画像の適切な管理

const ImageComponent = ({ src }) => {
  const [imageSrc, setImageSrc] = useState(null);
  
  useEffect(() => {
    const img = new Image();
    img.onload = () => {
      setImageSrc(src);
    };
    img.src = src;
    
    // 画像オブジェクトのクリーンアップ
    return () => {
      img.onload = null;
      img.src = '';
    };
  }, [src]);
  
  return imageSrc ? <img src={imageSrc} alt="画像" /> : <div>読み込み中...</div>;
};

画像オブジェクトも適切にクリーンアップしましょう。

無限スクロールでのメモリ管理

const InfiniteScrollComponent = () => {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  
  const loadMore = useCallback(async () => {
    setLoading(true);
    try {
      const response = await fetch(`/api/items?page=${Math.floor(items.length / 10)}`);
      const newItems = await response.json();
      
      setItems(prev => {
        // 一定数を超えたら古いアイテムを削除
        const allItems = [...prev, ...newItems];
        return allItems.length > 1000 ? allItems.slice(-1000) : allItems;
      });
    } finally {
      setLoading(false);
    }
  }, [items.length]);
  
  useEffect(() => {
    loadMore();
  }, []);
  
  return (
    <div>
      {items.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
      {loading && <div>読み込み中...</div>}
      <button onClick={loadMore}>さらに読み込む</button>
    </div>
  );
};

無限スクロールでは、アイテム数を制限して古いデータを削除することも大切です。

1000件を超えたら、古い1000件だけを残すようにしています。

まとめ:快適なReactアプリを作ろう!

お疲れ様でした! Reactでメモリリークを防ぐための重要なポイントをまとめます。

基本の対策

必ず覚えておきたいポイントです。

  1. useEffectのクリーンアップ関数を必ず実装する
  2. タイマーやイベントリスナーは適切に削除する
  3. 非同期処理では AbortController や フラグを使用する
  4. WebSocket や Observer は明示的にクリーンアップする
  5. カスタムフックで再利用可能なクリーンアップロジックを作成する

実践のコツ

開発で役立つポイントです。

  • return () => { /* クリーンアップ処理 */ }パターンを覚える
  • isMountedフラグで非同期処理を安全にする
  • カスタムフックで共通処理をまとめる
  • ブラウザの開発者ツールでメモリ使用量を監視する

重要なこと

メモリリークは徐々に影響が現れるため、開発初期から適切なクリーンアップを心がけることが大切です。

「最初は大丈夫だから後で対応しよう」と思っていると、後で大変なことになります。

定期的にメモリ使用量を監視し、長時間稼働するアプリケーションでは特に注意を払ってください。 適切なクリーンアップにより、パフォーマンスの良いReactアプリケーションを構築できますよ。

ぜひ、今回学んだ方法を実際のプロジェクトで活用してみてくださいね!

関連記事