React の useEffect 完全理解 — 依存配列の罠

概要

useEffect は React のフックの中でも最も使う機会が多く、そして最も誤解されやすいものです。特に「依存配列(第2引数)に何を書くか」で挙動が大きく変わり、初心者がハマる原因の大半はここに集中しています。

この記事では、useEffect の基本的な考え方から、依存配列にまつわる代表的な罠、そしてその回避方法までを、動く最小コードとともに整理します。

useEffect の基本モデル

useEffect は「レンダー後に実行される副作用」を宣言するためのものです。ポイントは「いつ実行されるかは依存配列が決める」ことです。

useEffect(() => {
  // 副作用(データ取得・購読・DOM操作など)
  return () => {
    // クリーンアップ(購読解除など)
  };
}, [dep1, dep2]); // 依存配列

依存配列の指定によって挙動は3パターンに分かれます。

// 1. 毎レンダー後に実行
useEffect(() => { /* ... */ });

// 2. 初回マウント時のみ実行
useEffect(() => { /* ... */ }, []);

// 3. dep が変化したときだけ実行
useEffect(() => { /* ... */ }, [dep]);

重要なのは、React が依存配列の各要素を「前回のレンダーの値」と Object.is で比較している点です。この仕組みを理解していないと、以降の罠にすべてハマります。

罠1: 依存配列に嘘をつく(値を書かない)

effect の中で使っている値を、依存配列に書かないケースです。

function Counter({ step }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount((c) => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, []); // ← step が抜けている

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

このコードは初回マウント時の step を閉じ込めてしまい、あとで step が変わっても古い値を参照し続けます。これが stale closure(古い値の閉じ込め) です。exhaustive-deps ルールはこれを警告します。まずは素直に依存へ入れましょう。

}, [step]);

罠2: オブジェクト・配列・関数を依存に入れる

依存配列は Object.is 比較のため、毎レンダーで新しい参照が作られるオブジェクト・配列・関数は毎回「変わった」と判定されます。

function Search({ query }) {
  const options = { query, limit: 20 }; // 毎レンダー新しい参照

  useEffect(() => {
    fetchData(options);
  }, [options]); // 毎レンダー実行されてしまう
}

対策A: プリミティブに分解する

useEffect(() => {
  fetchData({ query, limit: 20 });
}, [query]);

対策B: useMemo / useCallback で参照を安定させる

const options = useMemo(() => ({ query, limit: 20 }), [query]);
useEffect(() => {
  fetchData(options);
}, [options]);
const handleFetch = useCallback(() => {
  fetchData(query);
}, [query]);
useEffect(() => {
  handleFetch();
}, [handleFetch]);

罠3: クリーンアップを忘れて非同期が競合する

データ取得でクリーンアップを書かないと、古いリクエストが後で解決して新しい結果を上書きする レースコンディション が起きます。

useEffect(() => {
  let active = true;
  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => {
      if (active) setUser(data);
    });
  return () => {
    active = false;
  };
}, [userId]);

AbortController を使えばリクエスト自体をキャンセルできます。

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setUser)
    .catch((err) => {
      if (err.name !== "AbortError") throw err;
    });
  return () => controller.abort();
}, [userId]);

罠4: 関数更新で依存を減らす

state を「前の値から計算する」場合は、関数更新形式を使うことで state 自体を依存から外せます。

useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1); // count を依存に入れなくてよい
  }, 1000);
  return () => clearInterval(id);
}, []);

setCount(count + 1) だと count を依存に入れる必要がありますが、setCount((c) => c + 1) なら不要です。

確認方法

  • ESLint の react-hooks/exhaustive-deps を有効にすると、依存漏れはほぼ検出できます。
  • 無限ループを疑うときは effect 内に console.log を仕込み、実行回数を確認します。
  • React 18 の Strict Mode は開発時に effect を2回実行します。これはクリーンアップの不備をあぶり出す仕様で、本番では1回です。

注意点

  • 警告を消すために依存配列に嘘を書かない。
  • レンダー中に計算できる派生値は effect ではなく通常の変数(必要なら useMemo)で求める。
  • ユーザー操作の処理は effect ではなくイベントハンドラに書く。

まとめ

useEffect の罠の大半は「依存配列は Object.is 比較である」という一点に集約されます。使う値は素直に依存に入れ、参照が変わるものは分解か memo 化し、非同期はクリーンアップで競合を防ぎ、state 更新は関数形式で依存を減らす。まずは exhaustive-deps を有効にし、警告に素直に従うところから始めるのが最短の理解ルートです。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください