Reactのライフサイクルとは?Hooksで簡単に理解する

ReactコンポーネントのライフサイクルをuseEffectとHooksを使って分かりやすく解説。マウント、更新、アンマウントの仕組みを初心者向けに説明します。

Learning Next 運営
27 分で読めます

みなさん、Reactのライフサイクルという言葉を聞いたことはありますか?

「ライフサイクルって何?」「useEffectとの関係がわからない」と思ったことはありませんか?

この記事では、Reactのライフサイクルを現代的なHooksを使って分かりやすく解説します。 難しそうに見えますが、実は簡単に理解できるんです。

一緒にコンポーネントの「一生」について学んでいきましょう!

ライフサイクルとは

コンポーネントの「一生」

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

簡単に言うと、コンポーネントには3つの段階があります。

  • マウント:コンポーネントが画面に表示される
  • 更新:propsやstateが変更される
  • アンマウント:コンポーネントが画面から削除される

人の一生みたいですよね。 生まれて、成長して、最後にお別れするという感じです。

// ライフサイクルのイメージ
const MyComponent = () => {
  console.log('コンポーネントがレンダリングされました');
  
  useEffect(() => {
    console.log('マウント:画面に表示されました');
    
    return () => {
      console.log('アンマウント:画面から削除されます');
    };
  }, []);
  
  return <div>Hello World</div>;
};

このコードでは、コンポーネントが画面に表示されるときと削除されるときにメッセージが表示されます。 useEffectを使って、コンポーネントのライフサイクルを管理しているんです。

useEffectでライフサイクルを理解する

useEffectを使えば、ライフサイクルの各段階を簡単に扱えます。

1. マウント(componentDidMount相当)

コンポーネントが最初に画面に表示されるときの処理です。

const MountExample = () => {
  const [data, setData] = useState(null);
  
  // マウント時に1回だけ実行
  useEffect(() => {
    console.log('コンポーネントがマウントされました');
    
    // 初期データの取得
    fetchInitialData();
    
    async function fetchInitialData() {
      try {
        const response = await fetch('/api/data');
        const result = await response.json();
        setData(result);
      } catch (error) {
        console.error('データ取得エラー:', error);
      }
    }
  }, []); // 空の依存配列 = マウント時のみ
  
  return (
    <div>
      {data ? (
        <p>データ: {data.message}</p>
      ) : (
        <p>読み込み中...</p>
      )}
    </div>
  );
};

依存配列が空[])の場合、マウント時に1回だけ実行されます。 fetchInitialData関数でAPIからデータを取得しています。

async/awaitを使って非同期処理を行い、取得したデータをsetDataで状態に保存します。 エラーが発生した場合は、catch文でエラーメッセージを表示します。

2. 更新(componentDidUpdate相当)

propsやstateが変更されたときの処理です。

const UpdateExample = () => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');
  
  // countが変更されるたびに実行
  useEffect(() => {
    console.log('countが更新されました:', count);
    
    // ローカルストレージに保存
    localStorage.setItem('count', count.toString());
  }, [count]); // countを依存配列に指定
  
  // nameが変更されるたびに実行
  useEffect(() => {
    console.log('nameが更新されました:', name);
    
    // APIでプロフィール更新
    if (name) {
      updateProfile(name);
    }
  }, [name]); // nameを依存配列に指定
  
  const updateProfile = async (newName) => {
    try {
      await fetch('/api/profile', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: newName })
      });
    } catch (error) {
      console.error('プロフィール更新エラー:', error);
    }
  };
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        カウントアップ
      </button>
      
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="名前を入力"
      />
    </div>
  );
};

依存配列に変数を指定すると、その変数が変更されるたびに実行されます。

最初のuseEffectでは、countが変更されるたびにローカルストレージに保存しています。 2つ目のuseEffectでは、nameが変更されるたびにAPIでプロフィールを更新します。

複数のuseEffectを使うことで、異なる変更を別々に管理できるんです。

3. アンマウント(componentWillUnmount相当)

コンポーネントが画面から削除されるときの処理です。

const UnmountExample = () => {
  const [time, setTime] = useState(new Date());
  
  useEffect(() => {
    console.log('タイマーを開始します');
    
    // タイマーの設定
    const timer = setInterval(() => {
      setTime(new Date());
    }, 1000);
    
    // クリーンアップ関数(アンマウント時に実行)
    return () => {
      console.log('タイマーを停止します');
      clearInterval(timer);
    };
  }, []);
  
  return <div>現在時刻: {time.toLocaleTimeString()}</div>;
};

クリーンアップ関数return () => {})を使って、アンマウント時の処理を定義します。

setIntervalでタイマーを開始し、1秒ごとに現在時刻を更新しています。 コンポーネントがアンマウントされるときに、clearIntervalでタイマーを停止します。

このクリーンアップ処理をしないと、メモリリークの原因になってしまいます。

実際のライフサイクルパターン

よく使われるライフサイクルのパターンを見てみましょう。

パターン1: データ取得とクリーンアップ

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    let isMounted = true; // マウント状態の管理
    
    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);
        
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        
        // マウント状態を確認してから更新
        if (isMounted) {
          setUser(userData);
        }
      } catch (err) {
        if (isMounted) {
          setError(err.message);
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    };
    
    fetchUser();
    
    // クリーンアップ
    return () => {
      isMounted = false;
    };
  }, [userId]); // userIdが変更されたら再実行
  
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
};

このパターンでは、isMountedフラグでコンポーネントがまだ存在するかチェックしています。

fetchUser関数でAPIからユーザーデータを取得します。 非同期処理が完了する前にコンポーネントがアンマウントされた場合、状態の更新をスキップします。

userIdが変更されると、新しいユーザーのデータを取得し直します。

パターン2: イベントリスナーの管理

const WindowSize = () => {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    console.log('リサイズイベントを登録します');
    
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    // イベントリスナーを登録
    window.addEventListener('resize', handleResize);
    
    // クリーンアップでイベントリスナーを削除
    return () => {
      console.log('リサイズイベントを削除します');
      window.removeEventListener('resize', handleResize);
    };
  }, []); // マウント時のみ実行
  
  return (
    <div>
      ウィンドウサイズ: {windowSize.width} x {windowSize.height}
    </div>
  );
};

handleResize関数でウィンドウサイズの変更を検知します。 addEventListenerでイベントリスナーを登録し、ウィンドウサイズが変更されるたびに状態を更新します。

クリーンアップ関数でremoveEventListenerを呼び出し、イベントリスナーを削除します。 これにより、メモリリークを防げます。

パターン3: 複数の依存関係を持つ更新

const SearchResults = ({ query, category, sortBy }) => {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  
  useEffect(() => {
    // 検索パラメータが変更されたら検索を実行
    const performSearch = async () => {
      if (!query) {
        setResults([]);
        return;
      }
      
      setLoading(true);
      
      try {
        const params = new URLSearchParams({
          q: query,
          category,
          sort: sortBy
        });
        
        const response = await fetch(`/api/search?${params}`);
        const data = await response.json();
        
        setResults(data.results);
      } catch (error) {
        console.error('検索エラー:', error);
        setResults([]);
      } finally {
        setLoading(false);
      }
    };
    
    performSearch();
  }, [query, category, sortBy]); // 3つの値が変更されたら再実行
  
  return (
    <div>
      {loading ? (
        <div>検索中...</div>
      ) : (
        <div>
          {results.map(item => (
            <div key={item.id}>{item.title}</div>
          ))}
        </div>
      )}
    </div>
  );
};

複数の値を依存配列に指定することで、どれか1つでも変更されたら処理を実行できます。

performSearch関数で検索処理を行います。 URLSearchParamsでクエリパラメータを作成し、APIにリクエストを送信します。

検索中はloadingtrueにして、ローディング表示を行います。

クラスコンポーネントとの対比

クラスコンポーネントと関数コンポーネントの違いを見てみましょう。

クラスコンポーネント(旧式)

class OldComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }
  
  // マウント時
  componentDidMount() {
    console.log('マウントされました');
    this.timer = setInterval(this.tick, 1000);
  }
  
  // 更新時
  componentDidUpdate(prevProps, prevState) {
    if (prevState.count !== this.state.count) {
      console.log('countが更新されました');
    }
  }
  
  // アンマウント時
  componentWillUnmount() {
    console.log('アンマウントされます');
    clearInterval(this.timer);
  }
  
  tick = () => {
    this.setState({ count: this.state.count + 1 });
  };
  
  render() {
    return <div>Count: {this.state.count}</div>;
  }
}

クラスコンポーネントでは、それぞれのライフサイクルに対応したメソッドを定義します。 componentDidMountcomponentDidUpdatecomponentWillUnmountなどです。

関数コンポーネント(現代的)

const ModernComponent = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    console.log('マウントされました');
    
    const timer = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);
    
    return () => {
      console.log('アンマウントされます');
      clearInterval(timer);
    };
  }, []);
  
  useEffect(() => {
    console.log('countが更新されました');
  }, [count]);
  
  return <div>Count: {count}</div>;
};

関数コンポーネントでは、useEffectだけで全てのライフサイクルを管理できます。 より簡潔で理解しやすいコードになりますね。

クラスコンポーネントと比べて、書くコードの量も少なくなります。

実践的な活用例

実際のアプリケーションでよく使われるパターンを見てみましょう。

1. データ同期システム

const DataSync = ({ endpoint, interval = 5000 }) => {
  const [data, setData] = useState(null);
  const [lastUpdated, setLastUpdated] = useState(null);
  
  useEffect(() => {
    let mounted = true;
    
    const syncData = async () => {
      try {
        const response = await fetch(endpoint);
        const newData = await response.json();
        
        if (mounted) {
          setData(newData);
          setLastUpdated(new Date());
        }
      } catch (error) {
        console.error('同期エラー:', error);
      }
    };
    
    // 初回実行
    syncData();
    
    // 定期同期
    const timer = setInterval(syncData, interval);
    
    return () => {
      mounted = false;
      clearInterval(timer);
    };
  }, [endpoint, interval]);
  
  return (
    <div>
      <h3>データ同期</h3>
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
      {lastUpdated && (
        <p>最終更新: {lastUpdated.toLocaleTimeString()}</p>
      )}
    </div>
  );
};

このコンポーネントでは、定期的にAPIからデータを取得して同期しています。

syncData関数で非同期でデータを取得し、成功したら状態を更新します。 setIntervalで定期的に同期処理を実行します。

mountedフラグで、コンポーネントがアンマウントされた後の状態更新を防いでいます。

2. 状態の永続化

const PersistentCounter = () => {
  const [count, setCount] = useState(() => {
    // 初期値をローカルストレージから取得
    const saved = localStorage.getItem('counter');
    return saved ? parseInt(saved, 10) : 0;
  });
  
  // countが変更されるたびにローカルストレージに保存
  useEffect(() => {
    localStorage.setItem('counter', count.toString());
  }, [count]);
  
  // ページを離れる前に確認
  useEffect(() => {
    const handleBeforeUnload = (e) => {
      if (count > 0) {
        e.preventDefault();
        e.returnValue = '';
      }
    };
    
    window.addEventListener('beforeunload', handleBeforeUnload);
    
    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
    };
  }, [count]);
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(count - 1)}>-1</button>
      <button onClick={() => setCount(0)}>リセット</button>
    </div>
  );
};

useStateの初期値を関数で指定して、ローカルストレージから値を復元しています。

1つ目のuseEffectで、countが変更されるたびにローカルストレージに保存します。 2つ目のuseEffectで、ページを離れる前に確認ダイアログを表示します。

ページをリロードしても、カウントの値が保持されるようになります。

3. 外部ライブラリとの連携

const MapComponent = ({ center, zoom }) => {
  const mapRef = useRef(null);
  const [map, setMap] = useState(null);
  
  // マップの初期化
  useEffect(() => {
    if (mapRef.current && !map) {
      const newMap = new google.maps.Map(mapRef.current, {
        center,
        zoom
      });
      setMap(newMap);
    }
  }, [map, center, zoom]);
  
  // centerが変更されたらマップを移動
  useEffect(() => {
    if (map && center) {
      map.setCenter(center);
    }
  }, [map, center]);
  
  // zoomが変更されたらズームレベルを調整
  useEffect(() => {
    if (map && zoom) {
      map.setZoom(zoom);
    }
  }, [map, zoom]);
  
  return <div ref={mapRef} style={{ width: '100%', height: '400px' }} />;
};

Google Mapsのような外部ライブラリとの連携例です。

最初のuseEffectでマップを初期化し、状態に保存します。 2つ目のuseEffectで、centerが変更されたらマップの中心を移動します。 3つ目のuseEffectで、zoomが変更されたらズームレベルを調整します。

複数のuseEffectを使って、異なるpropsの変更を個別に処理しています。

よくある間違いと対処法

useEffectを使う際によくある間違いを見てみましょう。

間違い1: 依存配列の不備

// ❌ 問題のあるコード
const BadExample = ({ userId }) => {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, []); // userIdを依存配列に入れていない
  
  return <div>{user?.name}</div>;
};

// ✅ 正しいコード
const GoodExample = ({ userId }) => {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]); // userIdを依存配列に追加
  
  return <div>{user?.name}</div>;
};

依存配列にuseEffectで使用している変数を含めないと、期待通りに動作しません。

BadExampleでは、userIdが変更されても新しいユーザーデータを取得しません。 GoodExampleでは、userIdが変更されるたびに新しいデータを取得します。

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

// ❌ 問題のあるコード
const BadTimer = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);
    // クリーンアップがない
  }, []);
  
  return <div>{count}</div>;
};

// ✅ 正しいコード
const GoodTimer = () => {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);
    
    return () => clearInterval(timer); // クリーンアップを追加
  }, []);
  
  return <div>{count}</div>;
};

タイマーやイベントリスナーなどは、必ずクリーンアップが必要です。

BadTimerでは、タイマーが停止されずにメモリリークが発生します。 GoodTimerでは、コンポーネントがアンマウントされるときにタイマーを停止します。

クリーンアップを忘れると、アプリのパフォーマンスが悪化してしまいます。

まとめ

Reactのライフサイクルは、useEffectを使えば簡単に理解できます。

基本的なパターンをおさらいしましょう。

マウント: useEffect(() => {}, []) - 空の依存配列 更新: useEffect(() => {}, [value]) - 監視したい値を依存配列に アンマウント: return () => {} - クリーンアップ関数

重要なポイントをまとめます。

  • 依存配列を正しく設定しましょう
  • 必要に応じてクリーンアップ関数を実装しましょう
  • 非同期処理では適切な状態管理を行いましょう

現代のReact開発では、関数コンポーネント+Hooksが主流です。 クラスコンポーネントよりもシンプルで理解しやすいコードが書けます。

ライフサイクルの概念を理解することで、より効率的で安全なReactアプリケーションを構築できます。 ぜひuseEffectを活用して、ライフサイクルを意識したコンポーネント設計を心がけてくださいね!

関連記事