こんにちは、かつコーチです。
前回はuseEffectの基本と、副作用処理という考え方について解説しました。
今回はその中でも特に多くの人がつまずく「依存配列」に絞って、より実践的な落とし穴と対処法を解説していきます。
依存配列の基本ルールをおさらいする
ESLintのルールに従うのが基本
useEffectの依存配列は、「そのuseEffectの中で使っている外部の値(propsやstateなど)は、すべて依存配列に含める」というのが基本ルールです。
このルールを手動で守るのは意外と難しく、うっかり書き漏らすことが頻繁に起こります。
そこでReact公式が提供しているeslint-plugin-react-hooksというESLintプラグインを導入しておくと、依存配列の書き漏らしを警告として教えてくれます。
チーム開発はもちろん、個人開発でも導入しておくことを強くおすすめします。
なぜ依存配列を「省略」してはいけないのか
前回の記事でも触れましたが、依存配列を省略すると「毎回のレンダリング後に実行される」という挙動になります。
これは一見便利そうに思えますが、意図せず無限ループやパフォーマンス低下の原因になりやすいため、基本的には依存配列を明示するようにしましょう。
実践でハマりやすい依存配列のパターン
パターン1:オブジェクト・配列を依存配列に入れる
依存配列にオブジェクトや配列を入れると、意図せず毎回useEffectが実行されてしまうことがあります。
❌ Before:毎レンダリングで新しいオブジェクトが作られてしまう
import { useEffect, useState } from "react";
function SearchResult({ keyword }: { keyword: string }) {
const [results, setResults] = useState<string[]>([]);
// レンダリングのたびに新しいオブジェクトが生成される
const searchOptions = { keyword, limit: 10 };
useEffect(() => {
console.log("検索実行:", searchOptions);
setResults([`${searchOptions.keyword}の検索結果`]);
}, [searchOptions]); // 参照が毎回変わるため、毎回実行されてしまう
return <p>{results[0]}</p>;
}
export default SearchResult;
searchOptionsはコンポーネントが再レンダリングされるたびに新しく作られるオブジェクトです。
中身の値(keywordやlimit)が同じでも、参照としては毎回別物として扱われるため、依存配列に入れるとuseEffectが毎回実行されてしまいます。
私はこのパターンで「依存配列に一応値は入れているのに、なぜか無限に近い頻度でAPIが呼ばれてしまう」という現象に遭遇し、原因がオブジェクトの参照比較にあることに気づくまでかなり時間がかかりました。
✅ After:オブジェクトではなく、中のプリミティブな値を依存配列に入れる
import { useEffect, useState } from "react";
function SearchResult({ keyword }: { keyword: string }) {
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
const searchOptions = { keyword, limit: 10 };
console.log("検索実行:", searchOptions);
setResults([`${searchOptions.keyword}の検索結果`]);
}, [keyword]); // プリミティブな値なら、値が同じ限り再実行されない
return <p>{results[0]}</p>;
}
export default SearchResult;
オブジェクトをuseEffectの外で作らず、useEffectの中に移動させたうえで、依存配列には文字列や数値といったプリミティブな値だけを指定するのがポイントです。
こうすることで、「値そのもの」が変わったときだけuseEffectが再実行されるようになります。
パターン2:関数を依存配列に入れる
同じ理屈で、関数もレンダリングのたびに新しく作られるため、依存配列に入れると意図しない再実行の原因になりがちです。
import { useEffect, useState, useCallback } from "react";
function DataFetcher({ userId }: { userId: number }) {
const [data, setData] = useState<string | null>(null);
// useCallbackで関数自体をメモ化する
const fetchData = useCallback(() => {
console.log(`ユーザー${userId}のデータを取得`);
setData(`ユーザー${userId}のデータ`);
}, [userId]);
useEffect(() => {
fetchData();
}, [fetchData]);
return <p>{data}</p>;
}
export default DataFetcher;
useCallbackで関数自体をメモ化しておくことで、userIdが変わらない限りfetchDataの参照も変わらず、useEffectの不要な再実行を防げます。
「オブジェクト・配列・関数は、レンダリングのたびに新しく生成される」という性質を頭に入れておくと、依存配列がらみのバグを未然に防ぎやすくなります。
パターン3:古いstateを参照し続けてしまう
依存配列の設定を誤ると、useEffectの中で古いstateの値をずっと参照し続けてしまうことがあります。
import { useEffect, useState } from "react";
function StaleClosureExample() {
const [count, setCount] = useState(0);
useEffect(() => {
const timerId = setInterval(() => {
// 依存配列が[]のため、countは常に0のまま参照される
console.log("現在のcount:", count);
}, 1000);
return () => clearInterval(timerId);
}, []); // countを依存配列に入れていない
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
export default StaleClosureExample;
このコードでは、ボタンを押して画面のcountは更新されているのに、コンソールに出力されるcountはずっと0のままになります。
これはuseEffectが初回レンダリング時にしか実行されないため、その中のsetIntervalが「初回レンダリング時点のcount」をずっと覚えたまま(クロージャとして閉じ込めたまま)動き続けてしまうためです。
この現象は「stale closure(古いクロージャ)」と呼ばれ、useEffectまわりのバグの中でも特に発見しづらいものの一つです。
対処法としては、依存配列にcountを追加してタイマーを毎回張り直すか、setCount((prev) => prev + 1)のように「直前の値をもとに更新する」関数形式を使うか、のどちらかになります。
useEffect(() => {
const timerId = setInterval(() => {
setCount((prev) => prev + 1); // 関数形式なら依存配列にcountが不要
}, 1000);
return () => clearInterval(timerId);
}, []);
よくあるつまずきポイント・エラー対処のまとめ方
ESLint警告を無視しない
「動いているから」という理由で、依存配列に関するESLintの警告を// eslint-disable-next-lineでその場しのぎに消してしまう人を時々見かけます。
しかし、その警告は「このコードには潜在的なバグがあるかもしれません」というReactからの正直なサインです。
無理に警告を消す前に、「なぜこの値を依存配列に入れたくないのか」を一度立ち止まって考える癖をつけましょう。
多くの場合、値をuseEffectの中に移動する、useCallback・useMemoでメモ化する、といった対処で警告自体を解消できます。
応用・一歩先の使い方
依存配列の設計は「何が変わったら再実行すべきか」から逆算する
依存配列を考えるときは、「このコードの中で何を使っているか」を機械的にリストアップするのではなく、「そもそもこの副作用は、何が変わったときに再実行されるべきなのか」という業務要件から逆算すると、迷いにくくなります。
たとえば検索キーワードが変わったときだけ検索を実行したいなら、依存配列にはkeywordだけを入れ、limitのような固定値はuseEffectの中で完結させる、というように設計します。
「使っている値を全部入れる」という機械的なルールと、「本当に再実行してほしいタイミング」という意図の両方を意識すると、依存配列まわりのバグはかなり減らせます。
まとめ
この記事のポイント
- 依存配列にオブジェクト・配列・関数をそのまま入れると、参照が毎回変わるため意図せず再実行されやすい
- オブジェクトは
useEffectの中で作る、関数はuseCallbackでメモ化するなどの対処が有効 - 古いstateを参照し続ける「stale closure」は、依存配列の設定不足が主な原因
setState((prev) => ...)の関数形式を使うと、依存配列に含めなくても最新の値を扱える- ESLintの依存配列に関する警告は無視せず、必ず理由を確認してから対処する
次に読むべき記事
useEffectと依存配列の理解が深まったところで、次回はDOMを直接参照したり、再レンダリングを挟まずに値を保持したりできるuseRefの使い方を解説します。
useState・useEffectと合わせて理解しておくと、Reactで扱えるフックの幅がさらに広がります。
→ 次の記事:useRefの使い方:DOM参照と値の保持