[React Hooks] useEffect ガイド

[React Hooks] useEffect ガイド

公開
[React Hooks] useEffect ガイド

注: 自分の理解のため Github Copilot (Claude Sonnet 4.5) と壁打ちしながら作成した内容です。 Github Copilot による記述も多く含まれます。

目次


1. useEffect とは

1.1 概要

useEffect は React の Hooks の一つで、関数コンポーネントで副作用(side effects)を扱うためのフックです。

副作用とは:

  • DOM の直接操作
  • データフェッチング(API 呼び出し)
  • タイマーの設定
  • イベントリスナーの登録
  • ブラウザ API(localStorage、document.title など)の操作
  • サードパーティライブラリとの連携

これらの処理は、React のレンダリングロジックの外で実行される必要があります。useEffect はこれらの処理を適切なタイミングで実行するための仕組みを提供します。

1.2 基本構文

useEffect(() => {
  // 副作用の処理

  return () => {
    // クリーンアップ処理(オプション)
  };
}, [依存配列]);

構成要素:

  1. エフェクト関数 - 実行したい副作用の処理
  2. クリーンアップ関数 - リソースの解放や購読の解除(オプション)
  3. 依存配列 - エフェクトの再実行タイミングを制御

1.3 依存配列の役割

依存配列は、エフェクトがいつ実行されるかを制御します。

// パターン1: 依存配列なし → 毎回のレンダリング後に実行
useEffect(() => {
  console.log("毎回実行される");
});

// パターン2: 空の依存配列 → マウント時のみ実行
useEffect(() => {
  console.log("初回マウント時のみ実行");
}, []);

// パターン3: 特定の値を指定 → その値が変更されたときに実行
useEffect(() => {
  console.log("countが変更されたときに実行");
}, [count]);

// パターン4: 複数の値を指定 → いずれかの値が変更されたときに実行
useEffect(() => {
  console.log("userId または status が変更されたときに実行");
}, [userId, status]);

実行タイミング:

function Component({ userId }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("Effect実行");
  }, [userId]);

  // レンダリング → 画面更新 → Effect実行
  return <div>{count}</div>;
}

1.4 実行タイミングの詳細とメリット

useEffect は レンダリングをブロックしない(非ブロッキングな UI 更新) ように設計されています。これにより以下のメリットがあります。

実行フロー:

1. コンポーネントのレンダリング(JSXの評価)
2. DOM の更新(ブラウザへの反映)
3. 画面描画(ユーザーが見える)← ここまでは高速
4. useEffect の実行(副作用の処理)

メリット:

  1. パフォーマンスの向上 - 画面更新が副作用処理を待たないため、UI が即座に反応
  2. ユーザー体験の改善 - ユーザーは即座に画面の変化を確認でき、「反応が遅い」と感じにくい
  3. 最新の DOM にアクセス可能 - useEffect 実行時には既に DOM が更新済みなので、DOM 操作や測定が正確
useEffect(() => {
  // 重い処理があっても画面表示を待たせない
  fetchLargeData();

  // 既にDOMが更新されているので、正確な値が取得できる
  const height = divRef.current.offsetHeight;
}, []);

useLayoutEffect との違い:

もし画面更新に同期的に実行したい場合は useLayoutEffect を使います。

// useEffect: レンダリング → 画面更新 → Effect実行(非同期)
useEffect(() => {
  // データ取得、イベント登録など、ほとんどのケース
}, []);

// useLayoutEffect: レンダリング → Effect実行 → 画面更新(同期)
useLayoutEffect(() => {
  // DOM測定やレイアウト調整が必要な場合のみ
  // アニメーションのちらつき防止など
}, []);

1.5 クリーンアップ関数

クリーンアップ関数は、リソースのリークを防ぐために重要です。

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

  1. コンポーネントがアンマウントされるとき
  2. 次のエフェクトが実行される前(依存配列の値が変わったとき)
useEffect(() => {
  // セットアップ: リソースの作成・購読
  const subscription = api.subscribe(userId);

  // クリーンアップ: リソースの解放・購読の解除
  return () => {
    subscription.unsubscribe();
  };
}, [userId]);

具体例: タイマー

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // タイマーを開始
    const intervalId = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);

    // クリーンアップでタイマーを停止
    return () => {
      clearInterval(intervalId);
    };
  }, []);

  return <div>{seconds}秒</div>;
}

クリーンアップなしの場合の問題:

// ❌ 問題: クリーンアップしないとタイマーが残り続ける
useEffect(() => {
  setInterval(() => {
    console.log("実行中");
  }, 1000);
}, []); // コンポーネントがアンマウントされてもタイマーは動き続ける

2. 基本的な挙動

2.1 実行順序

複数の useEffect はコンポーネント内で定義された順番に実行されます。

function MyComponent() {
  useEffect(() => {
    console.log("1番目のuseEffect");
  }, []);

  useEffect(() => {
    console.log("2番目のuseEffect");
  }, []);

  useEffect(() => {
    console.log("3番目のuseEffect");
  }, []);

  // 出力順序: 1番目 → 2番目 → 3番目
}

2.2 依存配列による実行タイミング

異なる依存配列を持つ場合

useEffect は独立して依存配列を監視します。

function UserProfile({ userId, theme }) {
  // userIdが変更されたときのみ実行
  useEffect(() => {
    fetchUserData(userId);
  }, [userId]);

  // themeが変更されたときのみ実行
  useEffect(() => {
    applyTheme(theme);
  }, [theme]);

  // マウント時のみ実行
  useEffect(() => {
    initializeAnalytics();
  }, []);
}

依存配列なしの場合

依存配列を省略すると、毎回のレンダリング後に実行されます。

useEffect(() => {
  console.log("毎回実行される");
});

2.3 クリーンアップ関数の実行順序

複数のクリーンアップ関数

useEffect のクリーンアップ関数は定義順に実行されます。

function Example() {
  useEffect(() => {
    console.log("Effect 1 実行");
    return () => console.log("Cleanup 1");
  }, []);

  useEffect(() => {
    console.log("Effect 2 実行");
    return () => console.log("Cleanup 2");
  }, []);

  // マウント時:    Effect 1 → Effect 2
  // アンマウント時: Cleanup 1 → Cleanup 2
}

2.4 React 18 Strict Mode での挙動

React 18 の 開発環境では、<StrictMode> によって useEffect が意図的に 2回実行されます。

1. マウント → Effect 実行
2. アンマウント → Cleanup 実行(意図的)
3. 再マウント → Effect 再実行

これは「クリーンアップ関数が正しく実装されているか」を自動検証するための仕様であり、本番環境では発生しません

function Example() {
  useEffect(() => {
    console.log("Effect 実行");
    // 開発環境では "Effect 実行" が2回出力される

    return () => {
      console.log("Cleanup 実行");
      // クリーンアップが確実に呼ばれることを React が検証する
    };
  }, []);
}

注意点:

  • 開発中に「なぜか2回実行される」と感じたら Strict Mode が原因の可能性が高い
  • クリーンアップ関数を正しく実装していれば、2回実行されても問題は発生しない
  • Strict Mode での二重実行に耐えられないコードは、本番でも潜在的なバグを抱えている

3. 注意点とベストプラクティス

3.1 関心の分離

異なる関心事は別々の useEffect に分けるべきです。

// ❌ 悪い例: すべてを1つのuseEffectにまとめる
useEffect(() => {
  fetchUserData(userId);
  subscribeToNotifications();
  logAnalytics();
}, [userId]);

// ✅ 良い例: 関心事ごとに分離
useEffect(() => {
  fetchUserData(userId);
}, [userId]);

useEffect(() => {
  const unsubscribe = subscribeToNotifications();
  return unsubscribe;
}, []);

useEffect(() => {
  logAnalytics();
}, []);

3.2 依存配列の重複を避ける

同じ依存配列を持ち、かつ関連性の高い処理を行う useEffect は統合を検討しましょう。ただし、関心事が異なる場合は同じ依存配列でも分離すべきです(3.1 を参照)。

// ❌ 避けるべき: 同じ依存配列
useEffect(() => {
  doSomething();
}, [value]);

useEffect(() => {
  doAnotherThing();
}, [value]);

// ✅ 改善案: 関連性が高ければ統合
useEffect(() => {
  doSomething();
  doAnotherThing();
}, [value]);

3.3 無限ループに注意

依存配列の設定ミスで無限ループが発生する可能性があります。

// ❌ 危険: stateを更新するとuseEffectが再実行される
useEffect(() => {
  setCount(count + 1);
}, [count]);

// ✅ 正しい: 関数形式の更新を使用
useEffect(() => {
  setCount((prev) => prev + 1);
}, []);

3.4 非同期処理の取り扱い

useEffect で非同期処理を行う場合、クリーンアップで適切にキャンセルします。

なぜクリーンアップが必要なのか

メモリリークとエラーの防止 コンポーネントがアンマウントされた後に非同期処理が完了すると、以下の問題が発生します。

  1. 存在しないコンポーネントへの state 更新

    • React 17 以前では Warning: Can't perform a React state update on an unmounted component という警告が表示されていた(React 18 でこの警告は削除済み)
    • 警告は出なくなったが、不要な state 更新が走ること自体は非効率
  2. 不要なネットワークリクエストの継続

    • 画面遷移後も古いリクエストが完了を待ち続ける
    • リソースの無駄遣い
  3. 競合状態(Race Condition)

    • 複数のリクエストが異なる順序で完了する
    • 古いデータで新しいデータを上書きしてしまう

基本パターン: フラグによるキャンセル

useEffect(() => {
  let cancelled = false;

  async function fetchData() {
    const response = await fetch("/api/data");
    const data = await response.json();
    if (!cancelled) {
      setData(data);
    }
  }

  fetchData();

  return () => {
    cancelled = true;
  };
}, []);

AbortController を使った実践的なパターン

useEffect(() => {
  const abortController = new AbortController();

  async function fetchData() {
    try {
      const response = await fetch("/api/data", {
        signal: abortController.signal,
      });
      const data = await response.json();
      setData(data);
    } catch (error) {
      // AbortErrorは無視(正常なキャンセル)
      if (error.name !== "AbortError") {
        console.error("Fetch error:", error);
      }
    }
  }

  fetchData();

  return () => {
    abortController.abort();
  };
}, []);

競合状態の具体例と対策

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // ❌ 問題: userId=1のリクエストが遅延し、userId=2の後に完了すると
  // 古いデータ(userId=1)で新しいデータ(userId=2)を上書きしてしまう
  useEffect(() => {
    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
    }
    fetchUser();
  }, [userId]);

  // ✅ 解決策: クリーンアップで古いリクエストを無効化
  useEffect(() => {
    let active = true;

    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      if (active) {
        setUser(data);
      }
    }

    fetchUser();

    return () => {
      active = false;
    };
  }, [userId]);
}

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

useEffect(() => {
  // ❌ 危険: クリーンアップしないとタイマーが残り続ける
  setInterval(() => {
    console.log("実行中");
  }, 1000);
}, []);

// ✅ 正しい: クリーンアップでタイマーを停止
useEffect(() => {
  const timerId = setInterval(() => {
    console.log("実行中");
  }, 1000);

  return () => {
    clearInterval(timerId);
  };
}, []);

WebSocket 接続のクリーンアップ

useEffect(() => {
  const ws = new WebSocket("wss://api.example.com");

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

  ws.onerror = (error) => {
    console.error("WebSocket error:", error);
  };

  // クリーンアップで接続を閉じる
  return () => {
    ws.close();
  };
}, []);

4. 実践的な例

4.1 フォームバリデーション

function FormComponent({ userId, formData }) {
  // ユーザーデータの取得
  useEffect(() => {
    fetchUser(userId);
  }, [userId]);

  // フォームバリデーション
  useEffect(() => {
    validateForm(formData);
  }, [formData]);

  // 自動保存
  useEffect(() => {
    const timer = setTimeout(() => {
      autoSave(formData);
    }, 1000);

    return () => clearTimeout(timer);
  }, [formData]);
}

4.2 イベントリスナーの管理

function WindowComponent() {
  // リサイズイベント
  useEffect(() => {
    const handleResize = () => console.log("resized");
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  // スクロールイベント
  useEffect(() => {
    const handleScroll = () => console.log("scrolled");
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
  }, []);
}

5. useEffect を使うべきでない場面

5.1 レンダリング時の計算

派生値の計算に useEffect は不要です。レンダリング中に直接計算すべきです。

// ❌ 悪い例: useEffectで派生値を計算
function Cart({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price, 0));
  }, [items]);

  return <div>合計: {total}</div>;
}

// ✅ 良い例: レンダリング中に直接計算
function Cart({ items }) {
  const total = items.reduce((sum, item) => sum + item.price, 0);
  return <div>合計: {total}</div>;
}

// ✅ 重い計算の場合は useMemo を使用
function Cart({ items }) {
  const total = useMemo(
    () => items.reduce((sum, item) => sum + item.price, 0),
    [items],
  );
  return <div>合計: {total}</div>;
}

5.2 イベントハンドラで完結する処理

ユーザーアクションに応じた処理は、イベントハンドラに直接書くべきです。

// ❌ 悪い例: useEffectでイベント処理
function Form() {
  const [submitted, setSubmitted] = useState(false);

  useEffect(() => {
    if (submitted) {
      sendData();
      setSubmitted(false);
    }
  }, [submitted]);

  return <button onClick={() => setSubmitted(true)}>送信</button>;
}

// ✅ 良い例: イベントハンドラで直接処理
function Form() {
  const handleSubmit = () => {
    sendData();
  };

  return <button onClick={handleSubmit}>送信</button>;
}

5.3 Props や State の初期化

初期値の設定に useEffect は不要です。

// ❌ 悪い例: useEffectで初期化
function Component({ initialValue }) {
  const [value, setValue] = useState(0);

  useEffect(() => {
    setValue(initialValue);
  }, []);

  return <div>{value}</div>;
}

// ✅ 良い例: 初期値を直接設定
function Component({ initialValue }) {
  const [value, setValue] = useState(initialValue);
  return <div>{value}</div>;
}

5.4 State の連鎖更新

ある State の変更に応じて別の State を更新する場合、ロジックの整理を検討しましょう。

// △ 改善の余地あり: useEffect の連鎖で処理の流れが追いにくい
function SearchComponent() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 500);
    return () => clearTimeout(timer);
  }, [query]);

  useEffect(() => {
    if (debouncedQuery) {
      fetchResults(debouncedQuery);
    }
  }, [debouncedQuery]);
}

// ✅ 良い例: カスタムフックでロジックをまとめて可読性を向上
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

function SearchComponent() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetchResults(debouncedQuery);
    }
  }, [debouncedQuery]);
}

5.5 Props の同期

Props の値をそのまま State にコピーするのは不要です。Props が変更されたときに State をリセットしたい場合は、キーを使うか派生値として扱います。

// ❌ 悪い例: Props を State にコピーして同期する
function EditForm({ initialName }) {
  const [name, setName] = useState(initialName);

  // Props が変わるたびに State を上書き → useEffect は不要
  useEffect(() => {
    setName(initialName);
  }, [initialName]);

  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

// ✅ 良い例1: キーを使ってコンポーネントをリセット
function EditFormContainer({ userId, initialName }) {
  return <EditForm key={userId} initialName={initialName} />;
}

function EditForm({ initialName }) {
  const [name, setName] = useState(initialName);
  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

// ✅ 良い例2: 派生値として扱えるならそのまま props を使う
function DisplayName({ name }) {
  // State にコピーする必要がない
  return <div>{name}</div>;
}

注意: Props に基づいてデータフェッチングを行う場合(例: userId が変わったら API を呼ぶ)は、useEffect の正当な用途です(7.2 データフェッチング を参照)。


6. useEffect を多用すべきでない理由

6.1 パフォーマンスの問題

useEffect は追加のレンダリングサイクルを引き起こします。

// ❌ 問題: 2回レンダリングが発生
function Component({ data }) {
  const [processedData, setProcessedData] = useState([]);

  useEffect(() => {
    setProcessedData(processData(data));
  }, [data]);

  // 1回目: processedData = []
  // 2回目: processedData = 処理済みデータ
  return <div>{processedData.length}</div>;
}

// ✅ 改善: 1回のレンダリングで完了
function Component({ data }) {
  const processedData = useMemo(() => processData(data), [data]);
  // 1回目のみ: processedData = 処理済みデータ
  return <div>{processedData.length}</div>;
}

6.2 コードの複雑化

過度な useEffect の使用はコードを理解しにくくします。

  • データフローが追いにくい
  • デバッグが困難
  • 予期しない再レンダリングの原因になる

6.3 バグの温床

依存配列の管理ミスや、実行タイミングの誤解によるバグが発生しやすくなります。

// ❌ バグの例: 古いクロージャを参照
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 常に 0 が出力される
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 依存配列が空なので count は更新されない

  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

解決策1: count を依存配列に追加する

// ✅ count を deps に含めることで最新の値を参照
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 最新の count が参照される
    }, 1000);
    return () => clearInterval(timer);
  }, [count]); // count が変わるたびにタイマーが再生成される

  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

解決策2: ref を使って最新の値を参照する(タイマーを再生成したくない場合)

// ✅ ref 経由で常に最新の値を参照しつつ、タイマーは一度だけ作成
function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(countRef.current); // ref 経由で常に最新の値
    }, 1000);
    return () => clearInterval(timer);
  }, []); // タイマーは一度だけ作成

  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

7. useEffect を使うべき場面(適切な用途)

以下の場合に限って useEffect を使用すべきです。

7.1 外部システムとの同期

// ブラウザ API との連携
useEffect(() => {
  document.title = `${count}回クリックされました`;
}, [count]);

// サードパーティライブラリの初期化
useEffect(() => {
  const map = new GoogleMap(mapRef.current);
  return () => map.destroy();
}, []);

7.2 データフェッチング

useEffect(() => {
  let cancelled = false;

  fetchData(id).then((data) => {
    if (!cancelled) setData(data);
  });

  return () => {
    cancelled = true;
  };
}, [id]);

7.3 イベントリスナーの登録

useEffect(() => {
  const handleResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

7.4 タイマーやアニメーション

useEffect(() => {
  const timer = setInterval(() => {
    setSeconds((s) => s + 1);
  }, 1000);
  return () => clearInterval(timer);
}, []);

8. まとめ

基本原則:

  • 複数の useEffect は定義順に実行される
  • クリーンアップ関数も定義順に実行される
  • 関心事ごとに分離することで可読性と保守性が向上
  • 依存配列を正確に設定して無限ループを防ぐ
  • 非同期処理は適切にクリーンアップする

使用上の注意:

  • useEffect は外部システムとの同期にのみ使用する
  • 派生値の計算やイベント処理には使わない
  • 過度な使用はパフォーマンスとコードの可読性を低下させる

適切な用途:

  • ブラウザ API やサードパーティライブラリとの連携
  • データフェッチングと非同期処理
  • イベントリスナーの登録・解除
  • タイマーやアニメーションの制御