logo

鎌イルカのクラフト記

article icon

React勉強会資料 ライフサイクル編

はじめに

前回の導入編では、Reactの基本的な概念を学びました。今回はコンポーネントのライフサイクルについて深掘りしていきます。

ライフサイクルを理解することで、以下のようなことができるようになります:

  • コンポーネントが表示された時にタイマーやイベントリスナーをセットアップする
  • 画面を離れる時に、タイマーやイベントリスナーを適切に解除する
  • propsやstateの変更に応じて、外部システムとの同期を取る

コンポーネントのライフサイクルとは

ライフサイクルとは、コンポーネントが生まれてから消えるまでの一連の流れのことです。

人間の一生に例えると:

  1. 誕生(マウント): コンポーネントが画面に表示される
  2. 成長(更新): propsやstateが変わって再レンダリングされる
  3. 死(アンマウント): コンポーネントが画面から消える
function UserProfile() {
  // 1. 誕生:初回レンダリング時
  console.log('コンポーネントが生まれました');
 
  // 2. 成長:stateやpropsが変わると再レンダリング
  const [count, setCount] = useState(0);
 
  // 3. 死:画面から消える時(後で説明)
  return <div>ユーザープロフィール</div>;
}

クラスコンポーネント時代のライフサイクル(参考)

React Hooksが登場する前は、クラスコンポーネントでライフサイクルメソッドを使っていました。

// 古い書き方(クラスコンポーネント)
class ChatRoom extends React.Component {
  // マウント時:画面に表示された直後
  componentDidMount() {
    console.log('コンポーネントが表示されました');
    // WebSocket接続を開始
    this.ws = new WebSocket(`wss://chat.example.com/${this.props.roomId}`);
  }
 
  // 更新時:propsやstateが変わった後
  componentDidUpdate(prevProps) {
    if (this.props.roomId !== prevProps.roomId) {
      // roomIdが変わったらWebSocketを再接続
      this.ws.close();
      this.ws = new WebSocket(`wss://chat.example.com/${this.props.roomId}`);
    }
  }
 
  // アンマウント時:画面から消える直前
  componentWillUnmount() {
    console.log('コンポーネントが消えます');
    this.ws.close(); // WebSocket接続を閉じる
  }
 
  render() {
    return <div>チャットルーム</div>;
  }
}

問題点:

  • 複雑で覚えることが多い
  • 関連するロジックがバラバラに散らばる(接続・再接続・切断が3つのメソッドに分散)
  • コードが冗長になりがち

現代の方法:useEffect Hook

React 16.8以降、関数コンポーネント + Hooksが主流になりました。ライフサイクルはuseEffectフックで管理します。

useEffectの本来の目的

useEffectの目的は「外部システムとの同期」です。 Reactの外にある何かと、コンポーネントの状態を同期させるために使います。

import { useEffect, useState } from 'react';
 
function Timer() {
  const [seconds, setSeconds] = useState(0);
 
  // useEffect: 外部システム(ブラウザのタイマーAPI)との同期
  useEffect(() => {
    const timerId = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
 
    // クリーンアップ:タイマーを解除
    return () => clearInterval(timerId);
  }, []); // マウント時にセットアップ、アンマウント時にクリーンアップ
 
  return <div>{seconds}秒経過</div>;
}

「外部システムとの同期」とは具体的に何か?

Reactが管理していない外の世界とやり取りすることです:

  • タイマー: setIntervalsetTimeoutのセットアップと解除
  • イベントリスナー: window.addEventListenerの登録と解除
  • WebSocket: リアルタイム通信の接続管理
  • DOM API: IntersectionObserverMutationObserverなどの監視
  • ブラウザAPI: document.titleの更新、localStorageへのアクセス

依存配列:useEffectの実行タイミングを制御する

useEffectの第2引数(依存配列)で、いつ実行するかを制御できます。

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

useEffect(() => {
  console.log('毎回実行されます');
  // レンダリングのたびに実行される
});

注意: これはほとんど使いません。無限ループのリスクがあります。

// ❌ 無限ループの例
function BadExample() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    setCount(count + 1); // stateを更新
    // → 再レンダリング → useEffectが実行 → stateを更新 → ...(無限ループ)
  }); // 依存配列がない!
 
  return <div>{count}</div>;
}

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

useEffect(() => {
  console.log('初回マウント時のみ実行');
 
  // ウィンドウのリサイズを監視
  const handleResize = () => console.log(window.innerWidth);
  window.addEventListener('resize', handleResize);
 
  return () => window.removeEventListener('resize', handleResize);
}, []); // ← 空の配列

使い所:

  • イベントリスナーの登録(1回だけ)
  • タイマーのセットアップ
  • WebSocket接続の確立

パターン3: 特定の値の変更を監視

function PageTitle({ title }) {
  useEffect(() => {
    // ブラウザのタブタイトルをpropsに同期
    document.title = title;
  }, [title]); // ← titleが変わった時だけ実行
 
  return <h1>{title}</h1>;
}
function ChatRoom({ roomId }) {
  useEffect(() => {
    // roomIdに合わせてWebSocket接続を切り替え
    const ws = new WebSocket(`wss://chat.example.com/${roomId}`);
    ws.onmessage = (event) => console.log(event.data);
 
    return () => ws.close(); // 前の接続を閉じる
  }, [roomId]); // ← roomIdが変わった時だけ再接続
 
  return <div>ルーム: {roomId}</div>;
}

使い所:

  • propsやstateの変更に応じて外部システムを再同期
  • チャットルームIDが変わったら再接続
  • 表示テーマが変わったらDOM属性を更新

クリーンアップ処理:後片付けの重要性

コンポーネントが消える時や、次の副作用が実行される前にクリーンアップ処理を実行できます。

クリーンアップが必要な例

function Timer() {
  const [seconds, setSeconds] = useState(0);
 
  useEffect(() => {
    console.log('タイマー開始');
 
    // 1秒ごとにカウントアップ
    const timerId = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
 
    // クリーンアップ関数を返す
    return () => {
      console.log('タイマー停止');
      clearInterval(timerId); // タイマーを解除
    };
  }, []); // マウント時のみ
 
  return <div>{seconds}秒経過</div>;
}

クリーンアップしないとどうなる?

// ❌ クリーンアップなし(メモリリーク)
function BadTimer() {
  const [seconds, setSeconds] = useState(0);
 
  useEffect(() => {
    setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
    // クリーンアップがない!
    // → コンポーネントが消えてもタイマーが動き続ける
    // → メモリリーク、エラーの原因
  }, []);
 
  return <div>{seconds}秒経過</div>;
}

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

1. イベントリスナー

function WindowSize() {
  const [width, setWidth] = useState(window.innerWidth);
 
  useEffect(() => {
    const handleResize = () => {
      setWidth(window.innerWidth);
    };
 
    // イベントリスナーを登録
    window.addEventListener('resize', handleResize);
 
    // クリーンアップ:リスナーを解除
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
 
  return <div>画面幅: {width}px</div>;
}

2. WebSocket接続

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
 
  useEffect(() => {
    // WebSocket接続を開始
    const ws = new WebSocket(`wss://chat.example.com/${roomId}`);
 
    ws.onmessage = (event) => {
      setMessages(m => [...m, event.data]);
    };
 
    // クリーンアップ:接続を閉じる
    return () => {
      ws.close();
    };
  }, [roomId]); // roomIdが変わったら再接続
 
  return <ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>;
}

3. DOM監視(IntersectionObserver)

function LazyImage({ src, alt }) {
  const imgRef = useRef(null);
  const [isVisible, setIsVisible] = useState(false);
 
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect(); // 一度表示されたら監視を停止
        }
      },
      { threshold: 0.1 }
    );
 
    if (imgRef.current) {
      observer.observe(imgRef.current);
    }
 
    // クリーンアップ:監視を解除
    return () => observer.disconnect();
  }, []);
 
  return (
    <div ref={imgRef}>
      {isVisible ? <img src={src} alt={alt} /> : <div>読み込み中...</div>}
    </div>
  );
}

4. APIリクエストのキャンセル(AbortController)

useEffectでfetchを使う場面がゼロにはならないため、キャンセルパターンは知っておく価値があります。

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
 
    fetch(`/api/user/${userId}`, {
      signal: controller.signal
    })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name === 'AbortError') {
          console.log('リクエストがキャンセルされました');
        }
      });
 
    // クリーンアップ:リクエストをキャンセル
    return () => {
      controller.abort();
    };
  }, [userId]);
 
  return <div>{user?.name}</div>;
}

useEffectでのデータ取得は非推奨

かつてはuseEffect内でAPIデータを取得するのが一般的なパターンでした。しかし、現在のReactエコシステムではこのアプローチには多くの問題があるとされています。

// ⚠️ かつての一般的なパターン(現在は非推奨)
useEffect(() => {
  fetch('/api/users')
    .then(res => res.json())
    .then(data => setUsers(data));
}, []);

何が問題なのか:

  • ローディング状態の管理が煩雑: loadingerrordataを手動で管理する必要がある
  • レースコンディション: 複数のリクエストが競合した時の制御が難しい
  • キャッシュがない: 同じデータを何度も取得してしまう
  • サーバーサイドレンダリングに対応できない: SSR時にウォーターフォールが発生する

現在の推奨アプローチ:

方法特徴
TanStack Queryキャッシュ、再取得、エラーハンドリングを自動管理
SWR軽量なデータフェッチングライブラリ
Server Componentsサーバー側でデータ取得(Next.js等)

React 19ではuse()フックが導入され、Promiseを直接扱える新しい方法も登場しています。詳しくはHooks応用編で扱います。

覚えておくべきこと: useEffectの目的は「外部システムとの同期」です。データの取得は同期ではなく「イベント」(画面を開いた、ボタンを押した)に対する応答であり、専用のデータフェッチングツールを使うのが適切です。

実践例:TanStack Queryを使ったユーザー検索

useEffectの使い方と、データ取得の現代的なアプローチを組み合わせた実践的な例です。

import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
 
function UserSearch() {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');
 
  // useEffect: デバウンスの実装(タイマーAPIとの同期)
  useEffect(() => {
    const timerId = setTimeout(() => {
      setDebouncedQuery(query);
    }, 300);
 
    return () => clearTimeout(timerId);
  }, [query]);
 
  // データ取得はTanStack Queryに任せる
  const { data: results = [], isLoading, error } = useQuery({
    queryKey: ['users', debouncedQuery],
    queryFn: async () => {
      const res = await fetch(`/api/search?q=${debouncedQuery}`);
      return res.json();
    },
    enabled: debouncedQuery.length > 0, // 空文字の時は実行しない
  });
 
  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="ユーザー名を入力"
      />
      {isLoading && <p>検索中...</p>}
      {error && <p style={{ color: 'red' }}>検索に失敗しました</p>}
      <ul>
        {results.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

ポイント: useEffectはデバウンス(タイマーとの同期)だけに使い、データ取得はTanStack Queryに任せています。これにより、キャッシュ・エラーハンドリング・ローディング状態が自動で管理されます。

よくある間違いと対処法

間違い1: 依存配列を正しく指定していない

// ❌ 間違い:countが依存配列にない
function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const timerId = setInterval(() => {
      console.log(count); // 常に0が表示される
      setCount(count + 1); // 常に0 + 1 = 1になる
    }, 1000);
 
    return () => clearInterval(timerId);
  }, []); // countが依存配列にない!
 
  return <div>{count}</div>;
}
 
// ✅ 正解1:関数型の更新を使う
useEffect(() => {
  const timerId = setInterval(() => {
    setCount(c => c + 1); // 前の値を元に更新
  }, 1000);
 
  return () => clearInterval(timerId);
}, []); // 依存なし
 
// ✅ 正解2:依存配列に追加(ただしタイマーが再生成される)
useEffect(() => {
  const timerId = setInterval(() => {
    setCount(count + 1);
  }, 1000);
 
  return () => clearInterval(timerId);
}, [count]); // countを依存配列に追加

間違い2: useEffect内でasync/awaitを直接使う

// ❌ 間違い:useEffectをasyncにできない
useEffect(async () => {
  const data = await fetch('/api/user');
  // エラー!useEffectはクリーンアップ関数を返す必要がある
}, []);
 
// ✅ 正解:内部でasync関数を定義
useEffect(() => {
  const fetchUser = async () => {
    const res = await fetch('/api/user');
    const data = await res.json();
    setUser(data);
  };
 
  fetchUser();
}, []);

※ ただし前述の通り、データ取得にはTanStack QueryやSWRの使用を推奨します。

間違い3: useEffectを条件分岐の中に書く

// ❌ 間違い:Hooksは常に同じ順序で呼ばれる必要がある
function UserProfile({ isLoggedIn }) {
  if (isLoggedIn) {
    useEffect(() => {
      // これはNG!
    }, []);
  }
}
 
// ✅ 正解:useEffect内で条件分岐
function UserProfile({ isLoggedIn }) {
  useEffect(() => {
    if (isLoggedIn) {
      // 処理
    }
  }, [isLoggedIn]);
}

useEffectの実行順序

複数のuseEffectがある場合の実行順序を理解しましょう。

function LifecycleDemo() {
  const [count, setCount] = useState(0);
 
  console.log('1. レンダリング');
 
  useEffect(() => {
    console.log('3. useEffect(依存なし)');
  });
 
  useEffect(() => {
    console.log('4. useEffect(空の依存配列)- マウント時のみ');
  }, []);
 
  useEffect(() => {
    console.log(`5. useEffect(count = ${count}`);
  }, [count]);
 
  console.log('2. レンダリング終了前');
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>増やす</button>
    </div>
  );
}
 
// 初回マウント時の出力:
// 1. レンダリング
// 2. レンダリング終了前
// 3. useEffect(依存なし)
// 4. useEffect(空の依存配列)- マウント時のみ
// 5. useEffect(count = 0)
 
// ボタンクリック後:
// 1. レンダリング
// 2. レンダリング終了前
// 3. useEffect(依存なし)
// 5. useEffect(count = 1)

ポイント:

  • useEffectはレンダリング後に実行される
  • 空の依存配列のuseEffectは初回のみ
  • 依存配列に値があるuseEffectはその値が変わった時のみ

まとめ

Reactのライフサイクル管理はuseEffectで行います。最も大切なのは、**useEffectの目的は「外部システムとの同期」**だということです。

  1. 基本構文: useEffect(() => { /* 副作用 */ }, [依存配列])
  2. 実行タイミング:
    • [] - マウント時のみ
    • [value] - valueが変わった時
    • 依存配列なし - 毎レンダリング(通常使わない)
  3. クリーンアップ: return () => { /* 後片付け */ }
  4. useEffectに適した用途:
    • タイマーのセットアップと解除
    • イベントリスナーの登録と解除
    • WebSocket接続の管理
    • DOM監視(IntersectionObserver等)
    • ドキュメントタイトルの更新
  5. データ取得にはuseEffectを使わない: TanStack Query、SWR、Server Componentsを使いましょう

次回予告: 次の「Hooks基礎編」では、useState、useReducer、useContext、useRefなど、実務で必須のHooksを体系的に学びます。useEffectで学んだ知識をベースに、Reactの状態管理をマスターしましょう!

学習の順序について: 次にHooks基礎編を学んだ後、TypeScript編に進むことをお勧めします。アーキテクチャ編は実際にコードを書いた経験を積んでから学ぶと、より理解が深まります。

参考リンク

useEffect – ReactThe library for web and native user interfaces
favicon of https://ja.react.dev/reference/react/useEffectja.react.dev
ogp of https://ja.react.dev/images/og-reference.png
エフェクトを使って同期を行う – ReactThe library for web and native user interfaces
favicon of https://ja.react.dev/learn/synchronizing-with-effectsja.react.dev
ogp of https://ja.react.dev/images/og-learn.png
そのエフェクトは不要かも – ReactThe library for web and native user interfaces
favicon of https://ja.react.dev/learn/you-might-not-need-an-effectja.react.dev
ogp of https://ja.react.dev/images/og-learn.png

目次