React基礎講座 #10 useEffect

読了 10分

前回はフォームを扱う定石であるcontrolled componentを学びました。これまで私たちが作ってきたコンポーネントは自分の中ですべてのことが始まり終わっていました。ユーザー入力を受け取り、stateで保持し、画面に描く1サイクルがコンポーネントの中で閉じていたのです。今回はコンポーネントが外部世界とインタラクションしなければならないときに使うツールであるuseEffectを学びます。

Side Effectとは #

Side effect(副作用)はコンポーネントの核心の役割である「props/stateからJSXを作ること」以外のすべての作業を指します。

  • サーバーからデータを取ってくる(fetch)
  • タイマーの設定(setTimeoutsetInterval)
  • ブラウザAPIの使用(localStoragedocument.title変更など)
  • 外部ライブラリの初期化
  • イベントリスナーの登録(window.addEventListener)

これらの作業はすべてレンダリング結果(JSX)を作ることとは別物です。だからといってコンポーネントと無関係なわけでもありません — どんなデータを取ってくるか、いつタイマーを点けたり消したりするかは、コンポーネントのprops/stateによって決まるからです。

Reactはこうした作業をコンポーネント関数の本体に直接書くのではなく、useEffectの中に入れて処理することを推奨しています。

なぜ関数本体に直接書いてはいけない? #

次のコードを見てください。

問題のあるコード
function Profile({ userId }) {
  const [user, setUser] = useState(null);

  // 🚫 関数本体で直接 fetch
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data));

  return user ? <p>{user.name}</p> : <p>ロード中...</p>;
}

問題は2つです。

  1. レンダリングのたびにfetchが起きるsetUserでstateが変わると再びレンダリングされ、またfetchが起きて、またsetUser、…無限ループになります。
  2. レンダリングは速くて純粋であるべきというReactの原則に反します。

useEffectはこうした作業をレンダリングが終わった後、しかも必要なときだけ実行するように分離してくれる仕組みです。

useEffectの基本的な使い方 #

src/Profile.jsx
import { useState, useEffect } from 'react';

function Profile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  return user ? <p>{user.name}</p> : <p>ロード中...</p>;
}

export default Profile;

核心の形:

useEffect(() => {
  // 実行するコード
}, [依存, 配列]);
  • 第1引数: effect関数(実行するコード)
  • 第2引数: 依存配列(これらの値が変わるときだけeffectを再実行)

Reactはコンポーネントが画面に描かれた後にeffect関数を実行します。そして次のレンダリング時に依存配列の値が以前と同じならeffectをスキップし、異なれば再実行します。

依存配列の3つの形態 #

1. 空の配列 [] — 最初の1回だけ #

コンポーネントマウント時1回
useEffect(() => {
  console.log('コンポーネントが最初に画面に表示されました');
}, []);

配列が空なら依存する値がないので、effectは最初に1回だけ実行されます。初期データロード、1回だけ実行すればよい初期化作業によく使われます。

2. 依存性の明示 [a, b] — その値が変わるたびに #

userIdが変わるたびに
useEffect(() => {
  fetch(`/api/users/${userId}`).then(/* ... */);
}, [userId]);

userIdが変わると再びfetchします。同じ値が入ってきたら再実行しないので効率的です。effectの中で使ったすべてのprops/stateは依存配列に入れなければならないというのが基本ルールです(入れないと古い値を参照するバグが発生しやすい)。

3. 配列自体を書かない — 毎回のレンダリングごとに #

毎回のレンダリングごとに(ほとんど使わない)
useEffect(() => {
  console.log('レンダリングされた');
});

依存配列をまるごと省くと毎回のレンダリングごとにeffectが実行されます。ほとんど使うことがなく、たいてい意図しない無限ループの原因になるので意識的に使用しない方がよいです。

ヒント
依存配列は忘れたり抜かしたりしやすい部分なので、ReactのESLintプラグインのreact-hooks/exhaustive-depsルールが抜けた依存性を自動で捕まえてくれます。ViteのデフォルトのESLint設定に含まれているのでコード作成中に警告が出るはずです。警告を無視せずそのまま従えばたいてい正確です。

Cleanup関数 #

effectが登録したリソース(タイマー、イベントリスナー、購読など)は整理(クリーンアップ)が必要なときが多いです。コンポーネントが画面から消えたり、依存性が変わってeffectが再実行される直前に、以前のeffectの整理作業をしなければなりません。

useEffectのeffect関数が関数を返すと、その関数をReactがクリーンアップ時点で呼び出してくれます。

src/Clock.jsx
import { useState, useEffect } from 'react';

function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => clearInterval(id);  // cleanup
  }, []);

  return <p>{time.toLocaleTimeString()}</p>;
}

export default Clock;

このコンポーネントが消えるときにReactが返された関数を呼び出してclearIntervalでタイマーを整理します。クリーンアップがないとコンポーネントが消えた後もタイマーが生き残ってしまい、メモリリークと原因不明のバグを引き起こします。

依存性が変わるときもクリーンアップが呼び出されます #

userId変更時に以前のfetchを無視
useEffect(() => {
  let cancelled = false;

  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (!cancelled) setUser(data);
    });

  return () => {
    cancelled = true;
  };
}, [userId]);
  • userId1のときfetch開始
  • レスポンスが来る前にユーザーが別のページに移動してuserId2に変わる
  • Reactが以前のeffectのクリーンアップ(cancelled = true)を先に実行
  • 新しいeffectが実行されて2に対するfetch開始
  • 遅れて到着した1番のレスポンスはcancelled === trueなので無視される

このようなrace conditionの処理がクリーンアップのもう1つのよくある用途です。

よくあるパターン #

データを取ってくる #

src/UserProfile.jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('リクエスト失敗');
        return res.json();
      })
      .then(data => setUser(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error}</p>;
  if (!user) return null;
  return <p>{user.name}</p>;
}

loading/error/dataの3つのstateで非同期リクエストのすべての状態を表現するパターンが定石です。Promise.finally()でローディングを切れば、成功でも失敗でも一貫して処理されます。

注記
実務では直接useEffect + fetchを書くよりTanStack Queryのようなデータフェッチライブラリを使うことが多いです。キャッシング、リトライ、バックグラウンド同期などを勝手に処理してくれるからです。ただしそれらのライブラリも結局useEffectで作られたものなので、動作原理を理解するのに役立ちます。

イベントリスナーの登録 #

src/WindowSize.jsx
function WindowSize() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return <p>ウィンドウ幅: {width}px</p>;
}

addEventListenerremoveEventListenerはペアが合わなければならないので、クリーンアップが必須です。

document.titleのような外部状態の同期 #

ドキュメントタイトルをカウントと同期
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `カウント: ${count}`;
  }, [count]);

  // ...
}

document.titleはReactが管理する領域の外なので、私たちのstateと同期するにはeffectが必要です。

localStorage同期 #

設定値をlocalStorageに保存
function Settings() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') ?? 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);

  // ...
}

useStateの初期値に関数を渡すと、最初のマウント時のみ実行されます(localStorage.getItemが毎回のレンダリングごとに呼び出されるのを防ぐ)。その後themeが変わるたびにeffectが新しい値を保存します。

よくある間違い #

1. 依存性の抜け漏れ #

バグ — userIdが抜けている
useEffect(() => {
  fetch(`/api/users/${userId}`).then(/* ... */);
}, []);

空の配列にしておくと最初のレンダリングのuserIdでだけfetchし、その後userIdが変わっても再び取ってきません。ESLintが捕まえてくれるので警告に従ってください。

2. effectの中で無限にsetState #

無限ループ
useEffect(() => {
  setCount(count + 1);  // 🚫 依存性 [count] の中で count を変更
}, [count]);

stateを変えると再びレンダリングされ、依存性が変わったのでeffectが再び実行され、またstateが変わって…終わりのないループです。effectは外部世界を変更する役割であって、自分自身のstateを絶え間なく変えるためのツールではありません。

3. useEffectを使いすぎる #

React公式ドキュメントが強調する点です — 計算で済ませられることはuseEffectで処理しないでください。たとえば「名 + 姓」を結合した値が必要だからとuseEffectでstateを作るのはやり過ぎです。ただ関数本体で変数として計算すればよいです。

間違った例 — わざわざeffectで
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
正しい例 — ただの変数
const fullName = `${firstName} ${lastName}`;

useEffectが本当に必要かは「この作業が外部世界(サーバー、タイマー、DOM API…)と関係しているか?」で判断すればよいです。そうでなければほとんどuseEffectは不要です。

自分でやってみる #

簡単な時計 + ページタイトル同期コンポーネントを作ってみます。

src/ClockTitle.jsx:

src/ClockTitle.jsx
import { useState, useEffect } from 'react';

function ClockTitle() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  useEffect(() => {
    document.title = `現在時刻: ${time.toLocaleTimeString()}`;
  }, [time]);

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>{time.toLocaleTimeString()}</h2>
      <p>ブラウザのタブのタイトルも一緒に変わるか確認してみてください</p>
    </div>
  );
}

export default ClockTitle;

src/App.jsx:

src/App.jsx
import ClockTitle from './ClockTitle';

function App() {
  return <ClockTitle />;
}

export default App;

保存すると時計が1秒ごとに更新され、ブラウザタブのタイトルも一緒に変わります。2つのuseEffectがそれぞれ別の仕事をしながら協力する姿が見られます。1つ目は1秒ごとに時刻を更新し、2つ目は時刻が変わるたびにタブタイトルを同期します。

おわりに #

今回の記事ではコンポーネントが外部世界とインタラクションするツールであるuseEffectを学びました。核心のまとめ:

  • useEffect(fn, deps)depsが変わるたびにfnを実行
  • [] = 最初の1回、[a] = aが変わるとき、省略 = 毎回のレンダリング
  • 関数を返すとクリーンアップとして使用(タイマー/リスナー/購読の整理)
  • effectの中で使ったprops/stateは依存性にすべて含める
  • 単純計算はeffectで処理せずただの変数で

これまで私たちが扱ってきたすべてのコンポーネントは自分自身のstateを持っていました。しかし2つの兄弟コンポーネントが同じstateを共有しなければならない状況ならどうすればよいでしょうか?次の記事「React基礎講座 #11 stateのリフトアップ」では、こういう場合に使う核心パターンであるlifting state upを学んでみます。

X