React useEffect 完全ガイド - セットアップ・クリーンアップ・依存配列を徹底解説

React useEffect 完全ガイド - セットアップ・クリーンアップ・依存配列を徹底解説

作成日:
更新日:

useEffectとは何か

useEffectは、Reactコンポーネントを外部システムと同期させるためのフックです。

「外部システム」とは、React自身が管理していないものすべてを指します:

  • ブラウザAPI(document.titlelocalStorageaddEventListenerなど)
  • サーバーへのAPI呼び出し
  • WebSocket接続
  • サードパーティライブラリ
  • タイマー(setIntervalsetTimeout

重要: useEffectは「Reactの外」に出るための**脱出ハッチ(Escape Hatch)**です。Reactだけで完結する処理には使うべきではありません。


基本構文

TypeScript
useEffect(() => {
  // セットアップ関数(副作用を実行)
  
  return () => {
    // クリーンアップ関数(オプション)
  };
}, [dependencies]); // 依存配列

3つの構成要素

要素説明
セットアップ関数コンポーネントがDOMに追加された後に実行される
クリーンアップ関数コンポーネントがDOMから削除される前、または次のセットアップ前に実行される
依存配列再実行のトリガーとなる値のリスト

依存配列の3つのパターン

1. 依存配列なし:毎回実行

TypeScript
useEffect(() => {
  console.log('毎回のレンダリング後に実行');
});

使いどころ: ほとんどありません。通常は依存配列を指定すべきです。

2. 空の依存配列:マウント時のみ

TypeScript
useEffect(() => {
  console.log('マウント時に1回だけ実行');
  
  return () => {
    console.log('アンマウント時に実行');
  };
}, []);

使いどころ:

  • アプリ起動時の初期化処理
  • イベントリスナーの登録
  • 外部サービスへの接続

3. 依存配列あり:依存値が変わったら実行

TypeScript
useEffect(() => {
  console.log(`countが ${count} に変わりました`);
}, [count]);

使いどころ:

  • propsやstateの変化に応じた処理
  • 特定の値に基づくデータフェッチ
  • URLパラメータの変化への対応

実践例:チャットルーム接続

最も典型的なuseEffectの使用例を見てみましょう。

TypeScript
import { useState, useEffect } from 'react';
import { createConnection } from './chat';
 
function ChatRoom({ roomId }: { roomId: string }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');
 
  useEffect(() => {
    // セットアップ: 接続を確立
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    
    // クリーンアップ: 接続を切断
    return () => {
      connection.disconnect();
    };
  }, [serverUrl, roomId]); // serverUrlまたはroomIdが変わったら再実行
 
  return (
    <div>
      <p>接続先: {serverUrl}</p>
      <p>ルーム: {roomId}</p>
    </div>
  );
}

実行フロー

1. コンポーネントがマウント
   → セットアップ実行(接続)

2. roomIdが "general" → "music" に変化
   → クリーンアップ実行(旧接続を切断)
   → セットアップ実行(新接続を確立)

3. コンポーネントがアンマウント
   → クリーンアップ実行(接続を切断)

クリーンアップ関数の重要性

クリーンアップ関数は「後片付け」を行います。これを忘れるとメモリリーク予期しない動作が発生します。

必ずクリーンアップが必要なケース

イベントリスナー

TypeScript
useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth);
  };
  
  window.addEventListener('resize', handleResize);
  
  // 必須: リスナーを解除
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

タイマー

TypeScript
useEffect(() => {
  const intervalId = setInterval(() => {
    console.log('1秒ごとに実行');
  }, 1000);
  
  // 必須: タイマーをクリア
  return () => {
    clearInterval(intervalId);
  };
}, []);

API呼び出し(競合状態の防止)

TypeScript
useEffect(() => {
  let isCancelled = false;
  
  async function fetchData() {
    const response = await fetch(`/api/user/${userId}`);
    const data = await response.json();
    
    // キャンセルされていなければstateを更新
    if (!isCancelled) {
      setUser(data);
    }
  }
  
  fetchData();
  
  // クリーンアップ: キャンセルフラグを立てる
  return () => {
    isCancelled = true;
  };
}, [userId]);

WebSocket

TypeScript
useEffect(() => {
  const ws = new WebSocket('wss://example.com/socket');
  
  ws.onmessage = (event) => {
    setMessages(prev => [...prev, event.data]);
  };
  
  // 必須: 接続を閉じる
  return () => {
    ws.close();
  };
}, []);

依存配列のルール

ルール1: Effect内で使う全ての値を含める

TypeScript
// Good: 正しい: 全ての依存を含む
useEffect(() => {
  const connection = createConnection(serverUrl, roomId);
  connection.connect();
  return () => connection.disconnect();
}, [serverUrl, roomId]);
 
// Bad: 間違い: roomIdが依存配列にない
useEffect(() => {
  const connection = createConnection(serverUrl, roomId);
  connection.connect();
  return () => connection.disconnect();
}, [serverUrl]); // roomIdが変わっても再接続されない!

ルール2: 不要な依存を減らすにはコードを修正

依存配列から値を「削除」するのではなく、Effect自体を修正して依存を減らします。

TypeScript
// Bad: 関数を依存に含めると毎回実行される
function ChatRoom({ roomId }) {
  function createOptions() {
    return { serverUrl: 'https://localhost:1234', roomId };
  }
 
  useEffect(() => {
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // 毎回新しい関数が作られる
}
 
// Good: 関数をEffect内に移動
function ChatRoom({ roomId }) {
  useEffect(() => {
    function createOptions() {
      return { serverUrl: 'https://localhost:1234', roomId };
    }
    
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // roomIdだけが依存
}

ルール3: オブジェクトと配列は参照が変わる

TypeScript
// Bad: 毎回新しいオブジェクトが作られる
function Component() {
  const options = { theme: 'dark' }; // 毎レンダリングで新しい参照
  
  useEffect(() => {
    applyTheme(options);
  }, [options]); // 毎回実行される!
}
 
// Good: 解決策1: プリミティブ値を依存にする
function Component() {
  const theme = 'dark';
  
  useEffect(() => {
    applyTheme({ theme });
  }, [theme]); // 文字列は値で比較される
}
 
// Good: 解決策2: useMemoでオブジェクトをメモ化
function Component() {
  const options = useMemo(() => ({ theme: 'dark' }), []);
  
  useEffect(() => {
    applyTheme(options);
  }, [options]);
}

useEffectが不要なケース

公式ドキュメントには「You Might Not Need an Effect」というページがあります。以下のケースではuseEffectを使うべきではありません。

1. 計算値の導出

TypeScript
// Bad: 悪い例: useEffectで計算
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  const [fullName, setFullName] = useState('');
  
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  
  return <p>{fullName}</p>;
}
 
// Good: 良い例: レンダリング中に計算
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  
  // 単純に計算すればよい
  const fullName = firstName + ' ' + lastName;
  
  return <p>{fullName}</p>;
}

理由: useEffectを使うと「レンダリング → Effect実行 → state更新 → 再レンダリング」と2回レンダリングされます。

2. propsの変化に応じたstateリセット

TypeScript
// Bad: 悪い例: useEffectでリセット
function List({ items }) {
  const [selection, setSelection] = useState(null);
  
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}
 
// Good: 良い例: keyを使ってコンポーネントをリセット
function App() {
  const [items, setItems] = useState([...]);
  return <List items={items} key={items.id} />;
}
 
function List({ items }) {
  const [selection, setSelection] = useState(null);
  // keyが変わるとコンポーネント全体がリマウントされる
}

3. ユーザーイベントの処理

TypeScript
// Bad: 悪い例: useEffectでイベント処理
function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);
  
  useEffect(() => {
    if (product) {
      // 購入処理
      sendAnalytics('purchase', product.id);
    }
  }, [product]);
  
  return (
    <button onClick={() => setProduct(...)}>
      購入
    </button>
  );
}
 
// Good: 良い例: イベントハンドラで処理
function ProductPage({ productId }) {
  const handleBuy = () => {
    // 購入処理
    sendAnalytics('purchase', productId);
  };
  
  return (
    <button onClick={handleBuy}>
      購入
    </button>
  );
}

4. アプリ起動時の初期化(1回だけ)

TypeScript
// Bad: 悪い例: useEffectで初期化(Strict Modeで2回実行される)
function App() {
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
}
 
// Good: 良い例: モジュールスコープで初期化
let didInit = false;
 
function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
}
 
// Good: さらに良い例: アプリ起動前に初期化
if (typeof window !== 'undefined') {
  loadDataFromLocalStorage();
  checkAuthToken();
}
 
function App() {
  // ...
}

Strict Modeでの動作

React 18以降のStrict Mode(開発環境)では、useEffectは意図的に2回実行されます。

1回目: マウント → セットアップ実行
2回目: クリーンアップ実行 → 再マウント → セットアップ実行

目的: クリーンアップ関数が正しく実装されているかを検証するためです。

TypeScript
useEffect(() => {
  const connection = createConnection();
  connection.connect();
  
  return () => {
    connection.disconnect(); // これが正しく実装されていないと問題が発覚する
  };
}, []);

Strict Modeで2回実行されても問題ないEffect = 正しく実装されたEffect


Next.js(App Router)での注意点

Next.jsのApp Routerでは、Server ComponentsとClient Componentsの区別が重要です。

Server Componentsではフックは使えない

TypeScript
// app/page.tsx (Server Component)
 
// Bad: エラー: Server ComponentではuseEffectは使えない
export default function Page() {
  useEffect(() => {
    // ...
  }, []);
  
  return <div>...</div>;
}

Client Componentで使う

TypeScript
// app/page.tsx
import ClientComponent from './ClientComponent';
 
export default function Page() {
  return <ClientComponent />;
}
 
// app/ClientComponent.tsx
'use client'; // Client Componentを明示
 
import { useEffect, useState } from 'react';
 
export default function ClientComponent() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // クライアントサイドでのみ実行される
    fetchData().then(setData);
  }, []);
  
  return <div>{data}</div>;
}

SSR時の注意: typeof windowチェック

TypeScript
'use client';
 
useEffect(() => {
  // useEffect内はクライアントサイドでのみ実行されるので
  // windowチェックは不要
  window.localStorage.setItem('key', 'value');
}, []);
 
// ただし、useEffect外でwindowを参照する場合はチェックが必要
const isClient = typeof window !== 'undefined';

よくあるバグと解決策

1. 無限ループ

TypeScript
// Bad: 無限ループ
const [count, setCount] = useState(0);
 
useEffect(() => {
  setCount(count + 1); // stateを更新 → 再レンダリング → Effect実行 → ...
}, [count]);
 
// Good: 解決策: 関数形式でstateを更新
useEffect(() => {
  setCount(c => c + 1);
}, []); // 依存配列からcountを除外できる

2. 古いstateを参照(stale closure)

TypeScript
// Bad: 古いcountを参照してしまう
const [count, setCount] = useState(0);
 
useEffect(() => {
  const id = setInterval(() => {
    console.log(count); // 常に0が表示される
  }, 1000);
  return () => clearInterval(id);
}, []); // countが依存にない
 
// Good: 解決策1: 依存配列に含める
useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(id);
}, [count]);
 
// Good: 解決策2: 関数形式でstateにアクセス
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => {
      console.log(c); // 最新の値
      return c;
    });
  }, 1000);
  return () => clearInterval(id);
}, []);

3. 競合状態(Race Condition)

TypeScript
// Bad: 競合状態が発生する可能性
useEffect(() => {
  async function fetchData() {
    const response = await fetch(`/api/user/${userId}`);
    const data = await response.json();
    setUser(data); // userIdが変わった後も古いデータで更新される可能性
  }
  fetchData();
}, [userId]);
 
// Good: クリーンアップでキャンセル
useEffect(() => {
  let ignore = false;
  
  async function fetchData() {
    const response = await fetch(`/api/user/${userId}`);
    const data = await response.json();
    if (!ignore) {
      setUser(data);
    }
  }
  
  fetchData();
  
  return () => {
    ignore = true;
  };
}, [userId]);

まとめ

useEffectを使うべきとき

ケース
外部システムとの同期API呼び出し、WebSocket、ブラウザAPI
サブスクリプションの設定イベントリスナー、オブザーバー
手動でのDOM操作サードパーティライブラリの初期化

useEffectを使うべきでないとき

ケース代替手段
計算値の導出レンダリング中に計算
propsの変化に応じたリセットkeyプロパティ
ユーザーイベントの処理イベントハンドラ
1回だけの初期化モジュールスコープ

チェックリスト

  • クリーンアップ関数は必要か?(イベント、タイマー、接続は必須)
  • 依存配列に全ての依存を含めたか?
  • Strict Modeで2回実行されても問題ないか?
  • 本当にuseEffectが必要か?(計算やイベントで代替できないか)

useEffectは強力なツールですが、「外部システムとの同期」という本来の目的を常に意識し、不要な使用を避けることで、より保守しやすいReactアプリケーションを構築できます。


参考リンク