こんにちは。フロントエンドチームのシニアアーキテクトだ。
最近、コードレビューをしていて「またこれか…」と頭を抱えたポイントがある。それが、`useEffect`の依存配列(dependency array)にオブジェクトや配列をそのまま突っ込んでしまい、マウントされた瞬間にブラウザがフリーズするか、無限ループの沼にハマるという事故だ。
中級へのステップアップの過程で、誰もが一度はこの壁に激突する。公式ドキュメントには「参照の比較が行われます」とサラッと書いてあるが、あれの本当の意味と、ブラウザの裏側で何が起きているのかを腹落ちさせていないと、実務の複雑なコンポーネント設計では必ず足元をすくわれる。
今日は、なぜこの現象が起きるのか、そしてプロの現場ではどうやってこの罠をスマートに回避しているのかを、徹底的に解説しよう。
—
なぜオブジェクトや配列を依存配列に入れると無限ループするのか?
結論から言おう。Reactは、依存配列の値が「前回と変わったかどうか」を判定する際にあくまで「参照(メモリ上のアドレス)」の比較(厳密等価演算子 `===`)しか行っていないからだ。
ここで、JavaScriptのプリミティブ型(数値や文字列)と参照型(オブジェクトや配列)の違いを思い出してほしい。
// プリミティブ型:値が同じなら === は true
5 === 5 // true
“hello” === “hello” // true
// 参照型:中身が全く同じでも、作られたメモリの場所が違えば === は false
{ id: 1 } === { id: 1 } // false!
[1, 2, 3] === [1, 2, 3] // false!
これをReactのコンポーネントライフサイクルに当てはめて考えてみる。
1. コンポーネントがレンダリングされる。
2. 関数コンポーネントのスコープ内で、毎回新しくオブジェクト `{ theme: ‘dark’ }` が別のメモリ領域に再生成される。
3. `useEffect` が実行され、依存配列にあるオブジェクトの「参照」を確認する。
4. React「あれ? 前回のオブジェクトと、今渡されたオブジェクト、違う場所にある(=== が false)から、中身が変わっていなくても値が更新された! 副作用を実行しよう」
5. 副作用の中で状態(State)が更新される。
6. 状態が更新されたので、コンポーネントが再レンダリングされる。
7. 1に戻る(無限ループの完成 🎉)
ブラウザのメインスレッドは、この無限の再レンダリング処理で完全に息絶え、ユーザーには「画面が固まった(フリーズした)」ように見えるというわけだ。これが、現場で恐れられている「参照の罠」の正体だ。
—
現場で使える!実践的な回避策とコードパターン
じゃあ、オブジェクトや配列を条件に絡めて`useEffect`を動かしたい時はどうすればいいのか?
実務で即座に使える3つのアプローチを伝授しよう。
1. プリミティブな値(プリミティブ型)に分解して依存配列に渡す
一番手っ取り早くて、バグの温床になりにくいのがこれだ。オブジェクト全体を監視するのではなく、「そのオブジェクトの中の、本当に監視したいプリミティブなプロパティ」だけにバラして依存配列にブチ込む。
import React, { useState, useEffect } from ‘react’;
// 【NG例】userオブジェクトをそのまま依存配列に入れると再生成のたびに発火する
// useEffect(() => { fetchUserData(user); }, [user]);
// 【OK例】必要なプリミティブ値(IDなど)だけを依存配列にする
export const UserProfile = ({ user }) => {
const [profileData, setProfileData] = useState(null);
useEffect(() => {
// userオブジェクト全体ではなく、プリミティブな id のみを依存にする
// これなら親から渡される user オブジェクトの参照が変わっても、id が同じなら無駄に走らない
if (!user?.id) return;
console.log(‘APIフェッチ実行:’, user.id);
// fetchUserData(user.id).then(setProfileData);
}, [user?.id]); // 👈 ここがポイント!
return
;
};
2. `useMemo` でオブジェクトの参照を安定させる(メモ化)
どうしてもオブジェクトの形で子コンポーネントに渡したい、あるいはカスタムフックに渡したい場合がある。そんな時は、Reactの標準フックである `useMemo` を使って、依存している値が変わらない限り、同じオブジェクトの参照をキャッシュし続ける。
import React, { useState, useEffect, useMemo } from ‘react’;
export const SearchFilterComponent = () => {
const [keyword, setKeyword] = useState(‘React’);
const [page, setPage] = useState(1);
// 検索条件をまとめたオブジェクトを useMemo でメモ化する
// keyword や page が変わらない限り、この object のメモリ上のアドレス(参照)は固定される
const searchQuery = useMemo(() => {
return {
keyword: keyword,
page: page,
sort: ‘latest’,
};
}, [keyword, page]); // 👈 このプリミティブな値が変わった時だけオブジェクトが新しく作られる
useEffect(() => {
console.log(‘検索クエリが確定したのでAPIを叩きます’);
// searchApi(searchQuery);
}, [searchQuery]); // 👈 メモ化されているため、不要な再実行を防げる
return (
placeholder=”キーワードを入力”
/>
);
};
3. 【番外編】どうしてもオブジェクトを監視したい場合の最終手段(非推奨寄りだが知見として)
どうしてもロジックの都合上、動的なオブジェクト構造をそのまま監視せざるを得ない、あるいはサードパーティ製の複雑なオブジェクトを突っ込まざるを得ない場合、実務では JSON.stringify でシリアライズして文字列(プリミティブ)に変換してから比較する というテクニックが使われることがある。
ただし、これはプロパティの順番違いなどで意図しない判定ミスが起きるリスクや、巨大なオブジェクトだとシリアライズのコスト(パフォーマンスの低下)があるため、あくまで「どうしても」の時の最終防衛ラインとして覚えておいてほしい。
import React, { useState, useEffect, useRef } from ‘react’;
export const ComplexConfigComponent = ({ config }) => {
// オブジェクトをJSON文字列に変換して依存配列に渡す
// (※パフォーマンスがシビアな現場ではあまり推奨されないが、小規模な設定オブジェクトなら手軽)
const configString = JSON.stringify(config);
useEffect(() => {
console.log(‘コンフィグが変更されました(中身ベースで検知)’);
// 重い処理や初期化など
}, [configString]); // 👈 文字列なのでプリミティブとして安全に比較される
return
;
};
—
シニアからのまとめ・心構え
Reactの `useEffect` と依存配列の付き合い方は、フロントエンドエンジニアの「実力」がもろに出る部分だ。
「とりあえず動くからオブジェクトをそのまま入れとけ」という雑な実装をしていると、本番環境でユーザーのブラウザをクラッシュさせたり、パフォーマンス低下のボトルネックを作り出す犯人になりかねない。
- 「依存配列にあるオブジェクトや配列は、毎回のレンダリングで新しく作られている」という事実を常に頭の片隅に置いておくこと。
- 可能な限りプリミティブな値に分解して監視すること。
- どうしてもオブジェクトが必要なときは `useMemo` で参照をガッチリ固定すること。
この基本原則を守るだけで、君の書くReactコードの品質は一段も二段も跳ね上がるはずだ。
明日からのコードレビューや実装で、ぜひ意識してみてほしい。頼んだぞ!

コメント