useEffect — 使うべきタイミングと避けるべきタイミング
副作用の定義、依存配列、cleanup の役割、そして useEffect を使うべきでない場合を一緒に整理します。
9章で1部が終わりました。ここまで作ってきたコンポーネントは、自分自身の中ですべての出来事が始まり、終わっていました。ユーザー入力を受け取り、state に保管し、画面に描く一連のサイクルがコンポーネントの中で閉じていました。本章から2部が始まります。コンポーネントが 外の世界とやり取り しなければならないときに使う道具である useEffect を扱います。
useEffect は強力なぶん、誤用も多い道具です。本章では使い方だけでなく、「useEffect を使うべきでない場合」 も一緒に整理します。さらに25章(データフェッチとキャッシュ)では、Next.js の RSC 環境が useEffect + fetch のもっとも一般的な用途をどう置き換えるかも見ていきます。
Side Effect とは #
Side effect(副作用) とは、コンポーネントの本来の役割である「props / state から JSX を作ること」以外のすべての作業を指します。
- サーバーからデータを取得する(
fetch) - タイマーの設定(
setTimeout,setInterval) - ブラウザ API の使用(
localStorage,document.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つあります。
- レンダリングのたびに fetch が走る。
setUserで state が変わると再レンダリングされ、また fetch が走り、また setUser が呼ばれる、という無限ループになります。 - レンダリングは速く純粋であるべき という React の原則に反します。
useEffect は、こうした作業を レンダリングが終わった後で、しかも 必要なときだけ 実行するように切り分けてくれる仕組みです。
useEffect の基本的な使い方 #
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. 空配列 [] — 最初の一度だけ
#
useEffect(() => {
console.log('コンポーネントが初めて画面に現れました');
}, []);配列が空なら依存する値がないので、effect は最初に一度だけ実行されます。初期データの読み込み、一度だけ実行すれば済む初期化処理によく使われます。
2. 依存を明示 [a, b] — その値が変わるたびに
#
useEffect(() => {
fetch(`/api/users/${userId}`).then(/* ... */);
}, [userId]);userId が変わると再度 fetch します。同じ値が入ってくれば再実行しないので効率的です。effect の中で使ったすべての props / state は依存配列に入れる のが基本ルールです。入れないと古い値を参照するバグが起きやすくなります。
3. 配列自体を書かない — 毎レンダリング #
useEffect(() => {
console.log('レンダリングされた');
});依存配列を完全に省くと、毎レンダリングごとに effect が実行されます。実用例はほぼなく、たいてい意図しない無限ループの原因になるので、意識的に使わないほうがよいです。
react-hooks/exhaustive-deps ルールが抜けている依存を自動的に検出してくれます。Vite のデフォルト ESLint 設定に含まれており、コード作成中に警告が出ます。警告を無視せずそのまま従えば、ほとんどの場合正確です。Cleanup 関数 #
effect が登録したリソース(タイマー、イベントリスナー、購読など)は 片付け が必要なことが多いです。コンポーネントが画面から消えるとき、または依存が変わって effect が再実行される直前に、前回の effect の後始末を行う必要があります。
useEffect の effect 関数が関数を 返す と、React がその関数を cleanup の時点で呼び出します。
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 でタイマーを片付けます。cleanup がないと、コンポーネントが消えた後もタイマーが生き残り続け、メモリリークや原因不明のバグを引き起こします。
依存が変わるときも cleanup が呼ばれます #
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);userIdが1のときに fetch 開始- レスポンスが来る前にユーザーが別のページに移動し、
userIdが2に変わる - React が前回の effect の cleanup(
cancelled = true)を先に実行 - 新しい effect が実行され、
2に対する fetch を開始 - 遅れて届いた1番のレスポンスは
cancelled === trueなので無視される
このような race condition の処理が cleanup のもうひとつの典型的な用途です。
よくあるパターン #
データの取得 #
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 で作られているので、動作原理を理解しておくと役立ちます。
そして本書の4部(モダン Next.js)ではもう一歩踏み込みます。Server Components 環境では、データフェッチをクライアントの useEffect の中ではなく サーバーコンポーネント関数の本体で直接 行います。25章(データフェッチとキャッシュ)でそのモデルを扱います。
イベントリスナーの登録 #
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>;
}addEventListener ↔ removeEventListener は対で使う必要があるので、cleanup は必須です。
document.title のような外部状態の同期 #
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `カウント: ${count}`;
}, [count]);
// ...
}document.title は React の管理領域の外にあるため、自前の state と同期するには effect が必要です。
localStorage 同期 #
function Settings() {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') ?? 'light';
});
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
// ...
}useState の初期値に関数を渡すと、最初のマウント時にのみ実行されます。localStorage.getItem が毎レンダリングで呼ばれるのを防げます。以後、theme が変わるたびに effect が新しい値を保存します。
useEffect を使うべきでない場合 #
React 公式ドキュメントが強調する点です。計算で済む処理は useEffect で行わないでください。 useEffect 誤用の半分はここから始まります。
1. ほかの state から計算される値 #
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`;fullName は firstName / lastName から常に計算できる値です。別の state にすると、2つの値を常に一致させる責任が私たちに降ってきます。ただの変数にすれば毎回自動で正しい値が得られます。11章(状態のリフトアップ)の「Single Source of Truth」と同じ文脈です。
2. イベントハンドラの中で処理すべきこと #
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
sendAnalytics('submit');
}
}, [submitted]);
function handleSubmit() {
setSubmitted(true);
}function handleSubmit() {
sendAnalytics('submit');
}「クリックが起きたら X する」という処理は、クリックハンドラの中で直接やればよいです。state を経由して effect に迂回させる理由はありません。
3. 一度きりの初期化 #
function App() {
useEffect(() => {
initializeAnalytics();
}, []);
// ...
}initializeAnalytics();
function App() {
// ...
}アプリ全体で一度だけ実行すれば済む初期化コードは、コンポーネントの中に置く必要はありません。モジュールの最上位で一度実行すれば十分です。
判別基準 #
useEffect が本当に必要かは、次の一行で判断できます。
この作業は外の世界(サーバー、タイマー、DOM API、ブラウザ API)と関係しているか?
そうでないなら、ほとんどの場合 useEffect は必要ありません。
よくある誤り #
依存の抜け #
useEffect(() => {
fetch(`/api/users/${userId}`).then(/* ... */);
}, []);空配列にしておくと、初回レンダリングの userId だけで fetch し、以降 userId が変わっても再取得しません。ESLint が検出してくれるので警告に従いましょう。
effect の中で無限に setState #
useEffect(() => {
setCount(count + 1); // 🚫 依存 [count] の中で count を変更
}, [count]);state を変えると再レンダリングされ、依存が変わったので effect が再実行され、また state が変わる、という無限ループです。effect は外の世界を変える役割であって、自分自身の state を絶え間なく変えるための道具ではありません。
やってみよう #
シンプルな時計 + ページタイトル同期コンポーネントを作ってみます。
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;保存すると時計が1秒ごとに更新され、ブラウザのタブタイトルも一緒に変わります。2つの useEffect がそれぞれ別の仕事をしながら協力している様子が見えます。1つ目は1秒ごとに時刻を更新し、2つ目は時刻が変わるたびにタブタイトルを同期します。
練習問題 #
WindowSizeコンポーネントを作り、window.innerWidthとwindow.innerHeightを画面に表示し、ウィンドウサイズが変わるたびに自動で更新されるようにしてみてください。resizeイベントリスナーを登録し、cleanup で解除します。マウント時点で初期値も正確に読み込まれている必要があります。- useEffect 誤用を見抜く練習。次の2つの変数があるとき、どちらが useEffect を必要とするか判断してみてください。(a)
firstNameとlastNameからfullNameを計算、(b)userIdが変わるたびに/api/users/:idから情報を取得。(a) は useEffect なしで変数として、(b) は useEffect + cleanup で実装します。 - 自己片付けタイマー。
useStateでsecondsを持ち、マウント時点から1秒ごとに +1 加算するコンポーネントを作ってみてください。コンポーネントが消えるとき(例:親で条件付きで取り除くとき)にsetIntervalがきれいに解除され、メモリリークがないかコンソールで確認します。React strict mode の dev 環境で effect が2回実行される様子も合わせて観察するとよいです。
一行まとめ:
useEffect(fn, deps)はdepsが変わるたびにfnを実行する。[]なら最初の1回、[a]ならaが変わるとき、省略すると毎レンダリング。関数を返せば cleanup。effect の中で使った props / state はすべて依存に含める。単純な計算・イベント処理・一度きりの初期化は useEffect で処理しない。
次の章 #
ここまで扱ったすべてのコンポーネントは 自分自身の state を持っていました。ところが、2つの兄弟コンポーネントが同じ state を共有しなければならない状況ならどうしましょうか。次の 11章 状態のリフトアップでは、そのような場合に使う核心パターンである lifting state up を学びます。