React useEffectで副作用を制御する:実装パターンと落とし穴対策

ReactuseEffect フックは、コンポーネントの副作用を管理するための必須ツールです。この記事では、依存配列の正しい使い方、クリーンアップ関数の実装、無限ループの防止といった実践的なパターンを解説し、すぐにプロダクションコードで応用できる知識を提供します。

useEffect の基本構造を理解する

useEffect は、レンダリング後に副作用を実行するための React フックです。副作用とは、API 呼び出し、DOM 操作、タイマー設定など、コンポーネントの外部に影響を与える処理を指します。

基本的な構文は以下の通りです:

useEffect(() => {
  // ここに副作用の処理を記述
  console.log('エフェクト実行');
  
  return () => {
    // クリーンアップ関数(オプション)
    console.log('クリーンアップ実行');
  };
}, [依存配列]);

重要なポイントは、useEffect の第二引数である「依存配列」です。この配列の内容によって、エフェクトがいつ実行されるかが決まります。

依存配列を使い分ける 3 つのパターン

パターン 1:マウント時のみ実行(初期化処理)

依存配列を空配列 [] にすると、コンポーネントがマウント時に一度だけ実行されます。API 呼び出しや初期データ取得に最適です:

import React, { useEffect, useState } from 'react';

export function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // マウント時に一度だけ実行
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => console.error(err));
  }, []); // 空の依存配列

  if (loading) return 

読み込み中...

; return
ユーザー名: {user.name}
; }

パターン 2:特定の値が変わった時に実行

依存配列に値を指定すると、その値が変わるたびにエフェクトが実行されます。ユーザー ID が変わったときにデータを再取得するケースで有効です:

export function UserDetails({ userId }) {
  const [details, setDetails] = useState(null);

  useEffect(() => {
    // userId が変わるたびに実行
    console.log(`ユーザーID ${userId} のデータを取得中...`);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setDetails(data));
  }, [userId]); // userId を依存配列に指定

  return 
{details &&

{details.email}

}
; }

パターン 3:毎回実行(依存配列なし)

依存配列を指定しないと、レンダリングのたびにエフェクトが実行されます。通常は避けるべきですが、画面更新のたびに値を同期したい場合に使用します:

useEffect(() => {
  // 毎回実行される
  console.log('このコンポーネントがレンダリングされました');
}); // 依存配列なし

// ⚠️ これは無限ループの原因になりやすいので注意!

クリーンアップ関数でリソースリークを防ぐ

タイマーやリスナーなど、後処理が必要な場合はクリーンアップ関数を使用します。クリーンアップ関数は、エフェクトが再実行される前、またはコンポーネントがアンマウントされる時に実行されます:

export function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // タイマーを設定
    const intervalId = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);

    // クリーンアップ関数
    return () => {
      clearInterval(intervalId); // タイマーをクリア
      console.log('タイマーをクリアしました');
    };
  }, []);

  return 

経過秒数: {count}

; }

クリーンアップ関数を忘れると、タイマーが複数起動したり、イベントリスナーが重複登録されたりするため注意が必要です。

よくあるハマりポイントと対策

無限ループの原因:依存配列にオブジェクトや関数を指定する

JavaScript では、オブジェクトや関数は参照が異なると異なる値として扱われます。そのため、毎回新しいオブジェクトを依存配列に入れると、無限ループが発生します:

// ❌ 悪い例:無限ループが発生
export function BadExample() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(data => setData(data));
  }, [{ key: 'value' }]); // 毎回新しいオブジェクトが作成される

  return 
{data}
; } // ✅ 良い例:依存配列を修正 export function GoodExample() { const [data, setData] = useState(null); useEffect(() => { fetch('/api/data') .then(res => res.json()) .then(data => setData(data)); }, []); // 空の配列で初期化時のみ実行 return
{data}
; }

stale closure(古いクロージャ)の問題

依存配列が不完全な場合、古い値に基づいて処理が続行される場合があります:

// ❌ 問題のあるコード
export function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 常に初期値の 0 を出力
    }, 1000);

    return () => clearInterval(timer);
  }, []); // count を依存配列に含めていない

  return ;
}

// ✅ 修正版
export function CounterFixed() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 最新の count 値を出力
    }, 1000);

    return () => clearInterval(timer);
  }, [count]); // count を依存配列に含める

  return ;
}

useEffect を使うべき場面と使うべきでない場面

使うべき場面

  • API からデータを取得する
  • DOM に直接アクセスして操作する
  • イベントリスナーやタイマーを登録する
  • 外部ライブラリと連携する

使うべきでない場面

  • イベントハンドラ内の処理(onClick など)→ 直接ハンドラ関数に書く
  • 単純な値の変換・計算 → useMemo を使う
  • 複数の関連した状態を管理 → useReducer を使う

useEffect は副作用専用です。単なる計算処理は useMemo、複雑な状態管理は useContextuseReducer を検討してください。

実践例:データ取得 + エラーハンドリング

実際のプロダクション環境で必要なエラーハンドリングとローディング状態を含めたコード例です:

export function DataFetcher({ id }) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // abortController で不要なリクエストをキャンセル
    const abortController = new AbortController();

    const fetchData = async () => {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(`/api/items/${id}`, {
          signal: abortController.signal
        });
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // クリーンアップ:コンポーネント アンマウント時またはid変更時にリクエストをキャンセル
    return () => abortController.abort();
  }, [id]);

  if (loading) return 

読み込み中...

; if (error) return

エラー: {error}

; return data ?
{JSON.stringify(data)}
:

データなし

; }

このコードは以下の重要なパターンを含んでいます:

  • AbortController を使用して、前のリクエストがペンディング中に新しいリクエストが送信される場合にキャンセル
  • try-catch-finally で適切なエラーハンドリング
  • ローディング状態とエラー状態を分離管理

よくある質問

A: 技術的に制限はありませんが、同じロジックは一つの useEffect にまとめるのが実装上は綺麗です。ただし異なる関心事(データ取得と分析トラッキングなど)は、理解しやすさのために別々にしても構いません。

A: 依存配列を正しく指定していれば問題ありません。例えば useState の setter 関数は安定した参照なので依存配列に入れる必要がありません。無限ループは、依存配列が不完全な場合や依存配列を指定しない場合に起こります。

A: クラスコンポーネントの componentDidMount は初期化時のみ実行される一方、関数コンポーネントの useEffect は依存配列で制御されます。React 18 以降、開発環境では useEffect が 2 回実行される場合があります(Strict Mode)。これは正常な動作で、クリーンアップ関数で対応します。

まとめ

  • useEffect の第二引数(依存配列)で実行タイミングを制御する:空配列でマウント時のみ、値を指定で変更時に実行
  • クリーンアップ関数を忘れずに実装し、タイマーやリスナーは必ずクリアする
  • 無限ループは依存配列の不適切な指定が原因:オブジェクトや関数の参照に注意
  • API 呼び出しには AbortController を使用して前のリクエストをキャンセル
  • useEffect は副作用専用:単なる計算は useMemo、複雑な状態は useReducer を検討

詳細は React 公式ドキュメント useEffect をご参照ください。

動作環境: macOS 14 / Node.js 20.11 / React 18.2 で動作確認

K
AWS・Python・生成AIを専門とするソフトウェアエンジニア。AI・クラウド・開発ワークフローの実践ガイドを執筆しています。詳しく見る →