はじめに

前回の記事では、状態のリフトアップを使って複数コンポーネント間でデータを共有する方法を解説しました。本記事では、Reactの重要な概念である「副作用(Side Effects)」を処理するためのuseEffectフックについて詳しく解説します。

Reactコンポーネントの主な役割は、propsとstateに基づいてUIをレンダリングすることです。しかし、実際のアプリケーションでは、APIからのデータ取得、タイマーの設定、イベントリスナーの登録、ドキュメントタイトルの変更など、レンダリング以外の処理も必要になります。これらの処理を「副作用」と呼び、useEffectを使って適切に管理します。

本記事を読むことで、以下のことができるようになります。

  • useEffectの基本構文と動作原理の理解
  • 依存配列を使った実行タイミングの制御
  • クリーンアップ関数によるリソース解放
  • API呼び出しやタイマー処理の実装
  • 無限ループなどよくある問題の回避

実行環境・前提条件

必要な環境

  • Node.js 20.x以上
  • Viteで作成したReactプロジェクト(TypeScript推奨)
  • VS Code(推奨)

前提知識

  • 関数コンポーネントの基本
  • useStateの使い方
  • イベント処理の基本
  • 非同期処理(Promise/async-await)の基礎

副作用(Side Effects)とは何か

Reactにおける副作用の定義

Reactコンポーネントには、大きく分けて2種類の処理があります。

  1. レンダリング処理: propsとstateから計算してUIを返す純粋な処理
  2. 副作用処理: 外部システムとの同期、レンダリング以外で発生する処理

副作用の代表例を以下に示します。

カテゴリ 具体例
データ取得 API呼び出し、ローカルストレージの読み書き
購読 WebSocket接続、イベントリスナーの登録
タイマー setInterval、setTimeout
DOM操作 ドキュメントタイトルの変更、フォーカス制御
外部ライブラリ サードパーティライブラリの初期化・制御

なぜuseEffectが必要なのか

副作用をレンダリング中に直接実行すると、以下の問題が発生します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// これは正しくない例
function BadComponent() {
  const [data, setData] = useState(null);
  
  // レンダリング中にAPIを呼び出している
  fetch('/api/data')
    .then(res => res.json())
    .then(data => setData(data)); // 無限ループを引き起こす
  
  return <div>{data}</div>;
}

上記のコードでは、以下の問題が発生します。

  1. レンダリングのたびにfetchが実行される
  2. setDataが呼ばれると再レンダリングが発生
  3. 再レンダリングで再びfetchが実行される
  4. 1〜3が無限に繰り返される

useEffectを使うことで、「レンダリング完了後」に副作用を実行し、「いつ再実行するか」を制御できます。

useEffectの基本構文

useEffectは、Reactが提供するフック(Hook)の一つです。基本構文を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    // ここに副作用の処理を記述
    console.log('副作用が実行されました');
  });
  
  return <div>コンポーネント</div>;
}

useEffectの引数

useEffectは最大2つの引数を受け取ります。

1
useEffect(setup, dependencies?)
引数 説明
setup 関数 副作用の処理を行う関数。クリーンアップ関数を返すこともできる
dependencies 配列(省略可) 依存する値の配列。この値が変化したときに副作用が再実行される

最もシンプルな例:ドキュメントタイトルの変更

具体的な例として、コンポーネントの状態に応じてブラウザのタブタイトルを変更する処理を実装してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    // ブラウザのタブタイトルを更新
    document.title = `カウント: ${count}`;
  });
  
  return (
    <div>
      <p>現在のカウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        カウントアップ
      </button>
    </div>
  );
}

このコードでは、カウントが変化するたびにブラウザのタブタイトルが更新されます。

依存配列の役割と使い分け

useEffectの第2引数である「依存配列」は、副作用の実行タイミングを制御する重要な要素です。

依存配列のパターン

依存配列の指定方法によって、useEffectの動作が大きく変わります。

パターン 構文 実行タイミング
依存配列なし useEffect(() => {}) 毎回のレンダリング後
空の依存配列 useEffect(() => {}, []) マウント時のみ(1回だけ)
依存配列あり useEffect(() => {}, [a, b]) マウント時 + a,bが変化したとき

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

1
2
3
4
5
6
7
function Logger({ value }) {
  useEffect(() => {
    console.log('レンダリングされました');
  }); // 依存配列なし = 毎回実行
  
  return <div>{value}</div>;
}

依存配列を省略すると、コンポーネントがレンダリングされるたびにuseEffectが実行されます。多くの場合、これは意図した動作ではないため、適切な依存配列を指定することが推奨されます。

パターン2:空の依存配列(マウント時のみ)

1
2
3
4
5
6
7
8
function WelcomeMessage() {
  useEffect(() => {
    console.log('コンポーネントがマウントされました');
    // 初期化処理など、1回だけ実行したい処理
  }, []); // 空配列 = マウント時のみ
  
  return <div>ようこそ</div>;
}

空の配列[]を渡すと、コンポーネントが最初にマウントされたときだけ実行されます。初期化処理やイベントリスナーの登録など、1回だけ実行したい処理に適しています。

パターン3:依存配列あり(特定の値が変化したとき)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    console.log(`ユーザー ${userId} のデータを取得`);
    // userIdが変化したときだけ実行
    fetchUser(userId).then(data => setUser(data));
  }, [userId]); // userIdが変化したときに再実行
  
  return <div>{user?.name}</div>;
}

依存配列に値を指定すると、その値が変化したときにuseEffectが再実行されます。ReactはObject.isを使って前回の値と比較し、変化を検知します。

依存配列のルール

依存配列には、useEffect内で使用するすべての「リアクティブな値」を含める必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');
  
  useEffect(() => {
    // roomIdとserverUrlを使用している
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [serverUrl, roomId]); // 両方を依存配列に含める
  
  return <div>チャットルーム: {roomId}</div>;
}

ESLintのreact-hooks/exhaustive-depsルールを有効にしておくと、依存配列の漏れを自動で検出できます。

クリーンアップ関数の役割

副作用によっては、コンポーネントがアンマウントされるときや、依存配列の値が変化する前に「後片付け」が必要になります。これを行うのがクリーンアップ関数です。

クリーンアップ関数の基本

useEffectのセットアップ関数からクリーンアップ関数を返すことで、リソースの解放を行えます。

1
2
3
4
5
6
7
8
9
useEffect(() => {
  // セットアップ処理
  const subscription = subscribeToEvents();
  
  // クリーンアップ関数を返す
  return () => {
    subscription.unsubscribe();
  };
}, []);

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

クリーンアップ関数は以下のタイミングで実行されます。

  1. コンポーネントがアンマウントされる直前
  2. 依存配列の値が変化し、新しいエフェクトが実行される直前
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function Timer({ interval }) {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    console.log(`タイマー開始: ${interval}ms間隔`);
    const timerId = setInterval(() => {
      setCount(c => c + 1);
    }, interval);
    
    return () => {
      console.log(`タイマー停止: ${interval}ms間隔`);
      clearInterval(timerId);
    };
  }, [interval]);
  
  return <div>カウント: {count}</div>;
}

上記の例では、intervalが変化すると以下の順序で処理が実行されます。

  1. 古いintervalでのタイマーがクリーンアップ(clearInterval)
  2. 新しいintervalでのタイマーがセットアップ(setInterval)

クリーンアップが必要な典型的なケース

ケース セットアップ クリーンアップ
タイマー setInterval / setTimeout clearInterval / clearTimeout
イベントリスナー addEventListener removeEventListener
WebSocket new WebSocket() / connect() close() / disconnect()
購読 subscribe() unsubscribe()

実践的なuseEffectの使用例

例1:ウィンドウサイズの監視

ブラウザのリサイズイベントを監視し、ウィンドウサイズを状態として保持する例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { useState, useEffect } from 'react';

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });
  
  useEffect(() => {
    function handleResize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }
    
    // イベントリスナーを登録
    window.addEventListener('resize', handleResize);
    
    // クリーンアップでイベントリスナーを解除
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 空配列:マウント時に1回だけ登録
  
  return size;
}

function WindowSizeDisplay() {
  const { width, height } = useWindowSize();
  
  return (
    <div>
      <p>ウィンドウ幅: {width}px</p>
      <p>ウィンドウ高さ: {height}px</p>
    </div>
  );
}

このカスタムフックuseWindowSizeは、ウィンドウサイズの監視ロジックを再利用可能な形でカプセル化しています。

例2:カウントダウンタイマー

指定秒数からカウントダウンし、0になったら停止するタイマーを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { useState, useEffect } from 'react';

function CountdownTimer({ initialSeconds }) {
  const [seconds, setSeconds] = useState(initialSeconds);
  const [isRunning, setIsRunning] = useState(false);
  
  useEffect(() => {
    // タイマーが動作中でなければ何もしない
    if (!isRunning) return;
    
    // 0になったら停止
    if (seconds <= 0) {
      setIsRunning(false);
      return;
    }
    
    const timerId = setInterval(() => {
      setSeconds(s => s - 1);
    }, 1000);
    
    return () => clearInterval(timerId);
  }, [isRunning, seconds]);
  
  function handleStart() {
    setIsRunning(true);
  }
  
  function handleStop() {
    setIsRunning(false);
  }
  
  function handleReset() {
    setIsRunning(false);
    setSeconds(initialSeconds);
  }
  
  return (
    <div>
      <h2>カウントダウン: {seconds}</h2>
      <button onClick={handleStart} disabled={isRunning || seconds <= 0}>
        スタート
      </button>
      <button onClick={handleStop} disabled={!isRunning}>
        ストップ
      </button>
      <button onClick={handleReset}>
        リセット
      </button>
    </div>
  );
}

例3:APIからのデータ取得

外部APIからデータを取得し、ローディング状態とエラー状態も管理する例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // クリーンアップ用のフラグ
    let ignore = false;
    
    async function fetchUsers() {
      try {
        setIsLoading(true);
        setError(null);
        
        const response = await fetch('https://jsonplaceholder.typicode.com/users');
        
        if (!response.ok) {
          throw new Error('データの取得に失敗しました');
        }
        
        const data = await response.json();
        
        // コンポーネントがアンマウントされていなければ状態を更新
        if (!ignore) {
          setUsers(data);
        }
      } catch (err) {
        if (!ignore) {
          setError(err.message);
        }
      } finally {
        if (!ignore) {
          setIsLoading(false);
        }
      }
    }
    
    fetchUsers();
    
    // クリーンアップ関数
    return () => {
      ignore = true;
    };
  }, []);
  
  if (isLoading) {
    return <div>読み込み中...</div>;
  }
  
  if (error) {
    return <div>エラー: {error}</div>;
  }
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

ignoreフラグを使用することで、コンポーネントがアンマウントされた後に状態を更新しようとする「競合状態(Race Condition)」を防いでいます。

例4:ローカルストレージとの同期

状態をローカルストレージに保存し、ページをリロードしても値を保持する例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // ローカルストレージから初期値を読み込む
  const [value, setValue] = useState(() => {
    const saved = localStorage.getItem(key);
    if (saved !== null) {
      try {
        return JSON.parse(saved);
      } catch {
        return initialValue;
      }
    }
    return initialValue;
  });
  
  // 値が変化したらローカルストレージに保存
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  
  return [value, setValue];
}

function ThemeSettings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  
  return (
    <div>
      <p>現在のテーマ: {theme}</p>
      <button onClick={() => setTheme('light')}>ライト</button>
      <button onClick={() => setTheme('dark')}>ダーク</button>
    </div>
  );
}

よくある間違いと解決策

間違い1:無限ループ

useEffect内で状態を更新し、その状態を依存配列に含めると無限ループが発生します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// これは無限ループを引き起こす
function BadCounter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(count + 1); // 状態を更新
  }, [count]); // countが依存配列にある
  
  return <div>{count}</div>;
}

解決策: 更新関数形式を使用して依存配列からcountを除外する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function GoodCounter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timerId = setInterval(() => {
      setCount(c => c + 1); // 更新関数形式を使用
    }, 1000);
    return () => clearInterval(timerId);
  }, []); // 空配列でOK
  
  return <div>{count}</div>;
}

間違い2:オブジェクトや配列を依存配列に含める

オブジェクトや配列はレンダリングのたびに新しいインスタンスが作られるため、常に「変化した」と判定されます。

1
2
3
4
5
6
7
8
// これは毎回実行されてしまう
function BadExample({ userId }) {
  const options = { userId, limit: 10 }; // 毎回新しいオブジェクト
  
  useEffect(() => {
    fetchData(options);
  }, [options]); // 毎回異なるオブジェクトと判定される
}

解決策: オブジェクトをuseEffect内で作成する、またはプリミティブな値を依存配列に含める

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 解決策1: useEffect内でオブジェクトを作成
function GoodExample1({ userId }) {
  useEffect(() => {
    const options = { userId, limit: 10 };
    fetchData(options);
  }, [userId]); // プリミティブな値を依存配列に
}

// 解決策2: useMemoでオブジェクトをメモ化
function GoodExample2({ userId }) {
  const options = useMemo(() => ({ userId, limit: 10 }), [userId]);
  
  useEffect(() => {
    fetchData(options);
  }, [options]);
}

間違い3:クリーンアップの忘れ

イベントリスナーやタイマーを設定したまま解除しないと、メモリリークが発生します。

1
2
3
4
5
6
7
// メモリリークを引き起こす
function BadListener() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    // クリーンアップがない!
  }, []);
}

解決策: 必ずクリーンアップ関数を返す

1
2
3
4
5
6
7
8
function GoodListener() {
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, []);
}

間違い4:非同期関数を直接useEffectに渡す

useEffectのコールバックは非同期関数にできません。

1
2
3
4
5
// これはエラーになる
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

解決策: useEffect内で非同期関数を定義して呼び出す

1
2
3
4
5
6
7
useEffect(() => {
  async function loadData() {
    const data = await fetchData();
    setData(data);
  }
  loadData();
}, []);

開発モードでの二重実行について

React 18以降、Strict Modeが有効な開発環境では、useEffectが意図的に2回実行されます。これは、クリーンアップ関数の実装漏れを検出するためのストレステストです。

1
2
3
4
5
6
7
// 開発モードでの動作
// 1. マウント → セットアップ実行
// 2. クリーンアップ実行(ストレステスト)
// 3. セットアップ再実行

// 本番モードでの動作
// 1. マウント → セットアップ実行

この動作は本番環境では発生しません。二重実行で問題が発生する場合は、クリーンアップ関数の実装を見直してください。

useEffectを使うべきでないケース

React公式ドキュメントでは「Effectは必要ない場合がある」と明記されています。以下のケースではuseEffectを避けるべきです。

レンダリング中に計算できるデータ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// useEffectを使うべきでない例
function BadExample({ items }) {
  const [total, setTotal] = useState(0);
  
  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price, 0));
  }, [items]);
}

// 正しい例:レンダリング中に計算
function GoodExample({ items }) {
  const total = items.reduce((sum, item) => sum + item.price, 0);
  // useMemoで最適化することも可能
}

ユーザーイベントへの応答

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// useEffectを使うべきでない例
function BadExample() {
  const [query, setQuery] = useState('');
  
  useEffect(() => {
    if (query) {
      search(query);
    }
  }, [query]);
}

// 正しい例:イベントハンドラで直接処理
function GoodExample() {
  const [query, setQuery] = useState('');
  
  function handleSearch() {
    search(query);
  }
  
  return <button onClick={handleSearch}>検索</button>;
}

まとめ

本記事では、ReactのuseEffectフックについて基礎から実践まで解説しました。

重要なポイント

  1. useEffectの目的: 外部システムとの同期、副作用処理の実行
  2. 依存配列: 副作用の実行タイミングを制御する重要な要素
  3. クリーンアップ関数: リソースの解放、メモリリークの防止
  4. よくある間違い: 無限ループ、オブジェクトの依存、クリーンアップ忘れ

useEffectの使いどころ

使うべきケース 使うべきでないケース
API呼び出し レンダリング中の計算
イベントリスナーの登録 ユーザーイベントへの応答
タイマーの設定 propsからの値の導出
外部ライブラリとの同期 状態の初期化

次のステップ

次回の記事では、useRef、useMemo、useCallbackなど、その他の基本的なHooksについて解説します。これらのフックを使いこなすことで、DOM要素への参照やパフォーマンスの最適化が可能になります。

次に読む記事: React Hooks実践 - useRef・useMemo・useCallbackの使い分け(準備中)

参考リンク