【テクニカル・上級編】 参照型(オブジェクト・配列)の依存配列における注意点 – React実践ガイド

useEffectの依存配列に潜む「参照型」という名の爆弾:なぜ無限ループと無駄なレンダリングが起きるのか

やあ、アーキテクト。今日もコードレビューの海に溺れているかい?

画面が激しく点滅し、ブラウザのCPU使用率が突然100%に張り付く。コンソールには「Maximum update depth exceeded」の無慈悲なエラーログ。――Reactエンジニアであれば、一度は背筋が凍るようなこの光景に遭遇したことがあるはずだ。

犯人は大抵、`useEffect`の依存配列(dependency array)だ。そして、その中でも最も厄介で、多くの開発者を地獄に突き落とすのが「オブジェクトや配列といった参照型(Reference Types)」の取り扱いミスである。

今回は、JavaScriptの言語仕様の根幹とReactのレンダリングメカニズムの交差点に立ち、なぜ参照型が副作用の制御を狂わせるのか、そしてどうやってそれをねじ伏せるのかを、実務レベルのアーキテクチャの視点から徹底的に解剖しよう。

—

1. なぜ「同じに見えるもの」がReactにとって「全く別物」なのか?

まず、JavaScriptのプリミティブ型と参照型の違いを再確認しよう。文字列や数値、ブール値といったプリミティブ型は値そのものがメモリ上に格納されるため、値が同じであれば比較は常に一致(`===`)する。

しかし、オブジェクトや配列は違う。これらはヒープメモリ上の「アドレス(参照)」を保持している。

const obj1 = { id: 1, filter: ‘active’ };
const obj2 = { id: 1, filter: ‘active’ };

console.log(obj1 === obj2); // 爆笑の false

人間から見れば「同じ構造・同じ意味」のオブジェクトであっても、JavaScriptエンジンにとっては「メモリ上の別の住所にある、中身がたまたま似ているだけの他人」だ。

Reactの `useEffect` は、依存配列の要素を前回の描画時と今回の描画時で `Object.is`(厳密等価性:`===`)を用いて比較する。ここでオブジェクトや配列をそのまま依存配列に突っ込むとどうなるか?

コンポーネントが再レンダリングされるたびに、関数コンポーネントのスコープ内でリテラルとして定義されたオブジェクトは新しくメモリ上に再生成(再アロケーション)される。アドレスが変わるため、Reactはこう判定する。

> 「おい、依存配列のオブジェクトが変わってるぞ! 前回のと中身は知らんが住所が違う! 副作用を再実行しなきゃ!」

こうして、「副作用の実行 $\to$ 状態の更新(setState) $\to$ 再レンダリング $\to$ オブジェクトの再生成 $\to$ 依存配列の変化検知 $\to$ 副作用の再実行」という、美しくも残酷な無限ループ(Infinite Loop)が完成するわけだ。

—

2. 現場でありがちな「やってはいけない」アンチパターン

実務でよく見かける、最も典型的なバグコードを見てみよう。検索条件をオブジェクトで管理し、それが変更されたらAPIを叩き直すというよくあるユースケースだ。

import React, { useState, useEffect } from ‘react’;

// 【アンチパターン】レンダリングのたびにオブジェクトが再生成される地獄のコード
function UserSearchManager() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);

// 検索条件をひとまとめにしたオブジェクト(これが毎回新しく作られる)
const searchOptions = {
keyword: ‘react’,
role: ‘admin’,
};

useEffect(() => {
// 依存配列に searchOptions がいるのが運の尽き
const fetchUsers = async () => {
setLoading(true);
// APIリクエストのつもり
const res = await fetch(`/api/users?keyword=${searchOptions.keyword}&role=${searchOptions.role}`);
const data = await res.json();
setUsers(data);
setLoading(false);
};

fetchUsers();

// もしここで users などを更新する副作用があったら…完全なる無限ループの完成だ
}, [searchOptions]); // ⚠️ ここが危険信号!

return (

{loading ?

Loading…

:

{users.length} users found.

}

);
}

このコードは、コンポーネントが描画されるたびに `searchOptions` が新しく作られ、`useEffect` が発火し、無限にネットワークリクエストを送り続けるというサーバーキラーと化す。ブラウザのネットワークタブが赤く染まる快感(?)を味わいたいなら止めはしないが、プロダクション環境では即座にインシデントになる。

—

3. 堅牢なアーキテクチャのための回避策とベストプラクティス

では、この参照型の罠をくぐり抜け、パフォーマンスとメモリ効率を最大化するにはどうすればいいのか。シニアエンジニアが現場で使うアプローチをいくつか紹介しよう。

アプローチ A:オブジェクト全体ではなく「プリミティブな値」を依存配列にバラして渡す

最もシンプルかつ堅牢な方法だ。Reactの依存配列には、オブジェクト全体ではなく、比較対象となるプリミティブなプロパティ(文字列や数値)を直接バラして渡す。

import React, { useState, useEffect } from ‘react’;

function UserSearchManagerOptimized() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);

// 依存させたい値だけを個別の変数として定義(またはuseStateのステートにする)
const keyword = ‘react’;
const role = ‘admin’;

useEffect(() => {
const fetchUsers = async () => {
setLoading(true);
const res = await fetch(`/api/users?keyword=${keyword}&role=${role}`);
const data = await res.json();
setUsers(data);
setLoading(false);
};

fetchUsers();

// プリミティブ型なので、値が変わらない限り useEffect は再実行されない
}, [keyword, role]); // ✨ 安全!

return

{/ UI /}

;
}

これなら、JavaScriptの `===` 比較は純粋に値の比較になるため、無駄な再実行を防げる。

アプローチ B:`useMemo` による参照の安定化(ただし慎重に)

どうしてもオブジェクトのまま扱いたい、あるいは子コンポーネントへの不必要な再レンダリングを防ぐために参照を維持したい場合は、`useMemo` を使う。

import React, { useState, useEffect, useMemo } from ‘react’;

function UserSearchWithMemo() {
const [users, setUsers] = useState([]);
const [filterText, setFilterText] = useState(‘react’);

// useMemo を使って、依存しているプリミティブな値が変わらない限り、
// メモリ上の同じオブジェクト参照を維持し続ける
const searchOptions = useMemo(() => {
return {
keyword: filterText,
role: ‘admin’,
};
}, [filterText]); // filterText が変わったときだけ新しくオブジェクトを作る

useEffect(() => {
// searchOptions の参照が安定するため、無駄なフェッチが走らない
console.log(‘Effect triggered by stable object reference’);
}, [searchOptions]);

return

{/ UI /}

;
}

【アーキテクトからの警句】
`useMemo` は魔法の杖ではない。`useMemo` 自体の実行コストや、依存配列のチェックコストが存在する。単純なオブジェクトのメモ化のために毎回 `useMemo` を乱用するのは、コードを複雑にするだけのアンチパターンになり得る。基本は「アプローチA(プリミティブ分解)」を優先し、カスタムフックへの引数渡しなどでどうしても参照を固定したい場合のみ `useMemo` を検討しよう。

アプローチ C:配列の依存関係における「カスタム比較」とディープエフェクト

配列やネストしたオブジェクトをどうしても依存配列に入れたい場合、React公式はカスタムフックによる深い比較(Deep Comparison)を提案することがある。しかし、毎回のレンダリングで深いオブジェクトの構造比較を行うのはO(n)の計算量コストを支払い続けることになり、メインスレッドをブロックする原因になりかねない。

もし配列の要素のIDやプリミティブな値だけで比較が足りるなら、`JSON.stringify` を依存配列に使うというハック(非推奨とされることもあるが実務的妥協点)もあるが、キーの順序問題などがあるため、基本は「配列から一意なIDのリスト(プリミティブ配列)を抽出して依存配列にする」のが定石だ。

// ❌ 危険:items 配列全体を依存させる
useEffect(() => {
processItems(items);
}, [items]);

// ⭕️ 堅牢:アイテムのIDのリスト(プリミティブの配列)に変換して依存させる
const itemIds = useMemo(() => items.map(item => item.id), [items]);

useEffect(() => {
processItems(items);
}, [itemIds]); // IDの増減や順序変更のみを正確に検知する

—

4. チーフアーキテクトの総括

Reactの `useEffect` と参照型の関係は、私たちがどれだけJavaScriptのメモリモデルと仲良くなれているかを試すリトマス試験紙だ。

  • オブジェクトや配列を依存配列にそのまま置いていないか?
  • それは本当にオブジェクトのまま監視する必要があるか?
  • プリミティブな値に分解できないか?

この問いを常に自分に投げかけられるエンジニアこそが、メモリ効率が良く、予測可能で、スケールする堅牢なアプリケーションを構築できる。

フレームワークの背後にある「ブラウザがどうメモリを扱い、Reactがどう差分を検出しているのか」というプリミティブな好奇心と理解を忘れないでほしい。さあ、エディタに戻って、君のコードベースの依存配列を今一度見直してみようじゃないか。

コメント

タイトルとURLをコピーしました