ウェブエンジニア問題集

useEffect — 副作用と外部同期

useEffect は、DOMの更新が反映されたあとに実行される処理を書く場所です。 API呼び出し、購読の開始・停止、タイマー、ブラウザAPIの操作などが典型的な用途です。

学習者学習者

useEffect ってとりあえず何でも入れちゃってるけど、本当はどういう処理を書く場所なの?


useEffectの役割 — 外部との同期

useEffect は「副作用」と呼ばれますが、より正確には 「レンダー結果を外部と同期するための仕組み」 です。

「外部」とは、Reactが管理していないものすべてを指します。サーバーAPI、ブラウザのタイマー、localStorageやsessionStorage、DOM要素のサイズ・スクロール位置、WebSocket接続、サードパーティライブラリなどです。

import { useEffect, useState } from 'react';
 
export function Clock() {
  const [now, setNow] = useState(() => new Date());
 
  useEffect(() => {
    const id = window.setInterval(() => setNow(new Date()), 1000);
    return () => window.clearInterval(id); // クリーンアップ
  }, []);
 
  return <p>{now.toLocaleTimeString()}</p>;
}

この例では、ブラウザのタイマー(外部)とReactのstate(内部)を同期しています。


レンダーとマウントの違い

useEffect の依存配列を理解するには、レンダー・マウント・アンマウントの違いを押さえておく必要があります。詳しくは「レンダーとマウント — コンポーネントのライフサイクル入門」で解説しています。

要点だけ整理すると:

  • マウント — コンポーネントが最初にDOMに追加されること。ライフサイクル中に1回だけ起きます。
  • レンダー — コンポーネントの関数が呼ばれてUIを計算すること。stateやpropsが変わるたびに何度でも起きます。
  • アンマウント — コンポーネントがDOMから取り除かれること。

useEffect(..., []) が「マウント時のみ」と言われるのは、空配列だと依存する値がないため「何にも反応しない = 最初の1回しか実行されない」ためです。


依存配列の3パターン

第2引数の依存配列は「このエフェクトが何に反応するか」の宣言です。

依存配列なしと空配列 [] は変わるか

変わります。 見た目は似ていますが、実行タイミングがまったく異なります。

// 毎レンダー後に実行
useEffect(() => {
  console.log('rendered');
});
 
// マウント時のみ
useEffect(() => {
  console.log('mounted');
}, []);
書き方実行タイミング
依存配列なし毎レンダー後
[](空配列)マウント時に1回だけ
[value]マウント時 + value が変わるたび

[] — マウント時に1回だけ実行

useEffect(() => {
  // コンポーネントがマウントされたときに1回だけ実行
  const id = window.setInterval(() => setNow(new Date()), 1000);
  return () => window.clearInterval(id); // アンマウント時にクリーンアップ
}, []);

空配列は「依存する値がない=何にも反応しない」という意味です。初期化処理や、一度だけ立ち上げて解除する購読に使います。

[value] — 特定の値が変わったら再実行

useEffect(() => {
  fetchUserProfile(userId).then(setProfile);
}, [userId]);

userId が変わるたびにエフェクトが再実行されます。前回のクリーンアップが先に走ってから、新しいエフェクトが実行されます。

省略 — 毎レンダー後に実行

useEffect(() => {
  console.log('毎レンダー後に実行される');
});

依存配列を省略すると、あらゆるstate・propsの変化のたびに再実行されます。空配列 [] とは異なり、再レンダーのたびにエフェクトが走ります。意図しない無限ループの温床になるため、基本的に使いません。


クリーンアップ関数の実行タイミング

useEffect から返す関数(クリーンアップ)は、2つのタイミングで実行されます。

useEffect(() => {
  console.log(`接続開始: userId=${userId}`);
 
  return () => {
    console.log(`接続解除: userId=${userId}`);
  };
}, [userId]);

1. 依存値が変わって再実行される前

エフェクトが再実行されるとき、まず前回のクリーンアップが走り、そのあとに新しいエフェクトが実行されます。

// userIdが "alice" → "bob" に変わったとき
接続解除: userId=alice   ← 前回のクリーンアップ
接続開始: userId=bob     ← 新しいエフェクト

2. コンポーネントがアンマウントされるとき

コンポーネントが画面から取り除かれるとき、最後のクリーンアップが実行されます。

クリーンアップを忘れると、タイマーやイベントリスナーがコンポーネント消滅後も動き続ける「メモリリーク」が起きます。特に setIntervaladdEventListener は忘れやすいので注意してください。


イベントハンドラとの使い分け

useEffect に書くべきかイベントハンドラに書くべきかの判断は、「その処理はレンダーの結果として必要か、それともユーザーの操作に対する応答か」で決まります。

イベントハンドラで十分なケース

// ボタンを押したらAPIを呼ぶ → イベントハンドラでよい
function SubmitButton() {
  const handleClick = async () => {
    await fetch('/api/submit', { method: 'POST' });
  };
 
  return <button onClick={handleClick}>送信</button>;
}

ユーザーが「送信」ボタンを押したから実行する処理です。レンダーとは関係ないので useEffect は不要です。

useEffectが必要なケース

// ページが表示されたらデータを取得する → レンダーの結果として必要
function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState<Profile | null>(null);
 
  useEffect(() => {
    fetchUserProfile(userId).then(setProfile);
  }, [userId]);
 
  return profile ? <p>{profile.name}</p> : <p>読み込み中...</p>;
}

「このコンポーネントが表示されているなら、対応するデータが必要」という同期関係があります。これは useEffect の出番です。

不要なuseEffectの典型例

// NG — propsやstateから計算できる値をuseEffectで同期している
function FilteredList({ items, query }: { items: Item[]; query: string }) {
  const [filtered, setFiltered] = useState<Item[]>([]);
 
  useEffect(() => {
    setFiltered(items.filter((item) => item.name.includes(query)));
  }, [items, query]);
 
  return <ul>{filtered.map(/* ... */)}</ul>;
}
 
// OK — レンダー中に直接計算する
function FilteredList({ items, query }: { items: Item[]; query: string }) {
  const filtered = items.filter((item) => item.name.includes(query));
 
  return <ul>{filtered.map(/* ... */)}</ul>;
}

propsやstateから導出できる値は、レンダー中に計算すれば済みます。useEffect で別のstateに入れ直すのは、不要な再レンダーを生むだけです(計算コストが高い場合は次章以降の useMemo を使います)。


無限ループの典型パターン

学習者学習者

useEffect を書いたら、ブラウザが固まって無限ループした…!何が起きてるの?

「エフェクトの中でstateを更新 → 再レンダー → またエフェクトが走る」が止まらなくなるのが典型的な原因です。2つのパターンで見てみましょう。

パターン1: 依存配列に含めた値をエフェクト内で更新する

// NG — countが変わる → エフェクト実行 → countを更新 → また実行…
const [count, setCount] = useState(0);
 
useEffect(() => {
  setCount(count + 1); // 無限ループ!
}, [count]);

パターン2: 依存配列を省略してsetStateしている

// NG — 毎レンダー後に実行 → stateを更新 → 再レンダー → また実行…
const [data, setData] = useState(null);
 
useEffect(() => {
  fetchData().then(setData);
}); // 依存配列がない → 毎回実行

依存配列の省略は [] と書き忘れている場合がほとんどです。ESLintの警告を無視せずに対処することが重要で、これは次の章で詳しく扱います。


まとめ

useEffect はレンダー結果を外部と同期するためのフックです。API呼び出し、タイマー、購読など、Reactの外側にあるものとの接続に使います。

依存配列はエフェクトの再実行条件を宣言するもので、省略すると毎レンダー後に実行され、[] ならマウント時に1回だけ、[value] なら値の変化時に再実行されます。省略はほぼ書き忘れかバグと考えてよいです。

クリーンアップ関数は「再実行前」と「アンマウント時」の2タイミングで走ります。タイマーやイベントリスナーの解除を忘れないでください。

イベントハンドラで済む処理を useEffect に書くのは不要なuseEffectの典型です。「ユーザーの操作への応答」はイベントハンドラ、「レンダー結果に紐づく外部同期」はuseEffectと覚えてください。