依存配列におけるプリミティブ値の罠:`Object.is`の厳密性と、私たちが踏み抜く「見えない再実行」の地雷原
こんにちは。日々、ReactのレンダリングパイプラインとDOMの調停に頭を悩ませているアーキテクチャ・スペシャリストの皆さん。
今回は、`useEffect`をはじめとするHooksの依存配列(Dependency Array)における「プリミティブ値(Primitive values)」の挙動に焦点を当てます。
「おいおい、数値や文字列、真偽値なんて `===` で比較されるんだから、何も難しくないだろう」
そう思ったそこのあなた。甘い。非常に甘いと言わざるを得ません。
フレームワークの内部でどのような比較アルゴリズムが動き、JavaScriptエンジンやブラウザのメモリ効率、ひいては非同期処理の競合(Race Condition)にどう影響しているのか。その深層まで潜ったことがあるでしょうか。
今回は、プリミティブ値を依存配列に置いたときにReact内部で何が起きているのか、そのメカニズムと、実務の現場で絶対に踏んではいけない地雷の回避策を、ギークな視点から徹底的に解剖していきます。
—
1. `Object.is` の厳密な世界:Reactの依存性追跡メカニズム
React(厳密にはその調停アルゴリズムであるFiberアーキテクチャ)は、フックの依存配列を評価する際、lodashの `isEqual` のような深い比較(Deep Comparison)は絶対に行いません。そんなことをすれば、毎レンダリング時のO(n)計算量でメインスレッドが窒息死します。
Reactが行っているのは、非常にシンプルな浅い比較(Shallow Comparison)、正確にはJavaScriptの標準関数である `Object.is(value1, value2)` による比較です。
ここで、JavaScriptのプリミティブ値における `Object.is` の挙動を思い出してください。基本的には `===` とほぼ同じですが、いくつか重要な差異があります。
- `Object.is(0, -0)` は `false`
- `Object.is(NaN, NaN)` は `true`
この「`NaN` の扱い」を除けば、数値、文字列、真偽値の比較は極めて直感的です。しかし、問題は「値が変わっていないのに、親の再レンダリングの波及によって毎回新しいインスタンスのように扱われるケース」や、「プリミティブの皮を被った状態の不安定さ」にあります。
レンダリングサイクルと再実行のトリガー
`useEffect` は、前回のレンダリング時に記録された依存配列の各要素と、今回のレンダリング時のそれを `Object.is` で比較します。たった1つでも `Object.is` が `false` を返せば、Reactは「副作用を再実行すべきだ」と判断します。
ここで私たちが陥りがちなのが、「プリミティブ値だから安全だ」という思い込みです。次のセクションで、よくある実務のアンチパターンを見てみましょう。
—
2. 実務で遭遇する「見えないバグ」:プリミティブ値の罠
以下のコードを見てください。一見、何の問題もないように思えます。
import React, { useState, useEffect } from ‘react’;
// 親コンポーネントから数値やフラグを受け取る子コンポーネントを想定
interface ProfileCardProps {
userId: number;
isEditable: boolean;
}
export const ProfileCard: React.FC
const [userData, setUserData] = useState
// ユーザーIDや編集フラグが変わるたびにデータをフェッチし直す
useEffect(() => {
let isCancelled = false;
async function fetchUserData() {
console.log(`Fetching data for user: ${userId}, editable: ${isEditable}`);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!isCancelled) {
setUserData(data);
}
} catch (error) {
console.error(‘Failed to fetch user’, error);
}
}
fetchUserData();
// クリーンアップ関数で非同期処理の競合を防ぐ
return () => {
isCancelled = true;
};
}, [userId, isEditable]); // プリミティブ値を依存配列に指定
return (
);
};
このコード、`userId` も `isEditable` もプリミティブ値(`number` と `boolean`)なので、依存配列に入れるのは教科書通りです。しかし、アーキテクチャの観点から見ると、いくつかの致命的なリスクをはらんでいます。
リスクA: 不必要な再フェッチとメモリ・ネットワーク帯域の無駄遣い
親コンポーネントの設計が未熟な場合、`userId` や `isEditable` の値自体は変わっていなくても、親が再レンダリングされるたびに、これらのプリミティブ値が「新しく生成されたプリミティブ(値は同じだが)」として子に渡されることがあります。
JavaScriptのプリミティブは値渡しなので、基本的には値が同じなら `Object.is` は `true` を返しますが、親コンポーネント側で毎回インラインで値を変形(例: `isEditable={!!someState}` や `userId={Number(idString)}`)している場合、毎回のレンダリングで新しい値が生成され、`Object.is` の比較結果は `false` になります。結果として、意図しないAPIリクエストの嵐が発生します。
リスクB: 非同期の競合(Race Condition)とクロージャの古い参照
`userId` が頻繁に切り替わるユースケースを想像してください。
ユーザーA(ID: 1)のリクエストが走っている最中に、ユーザーB(ID: 2)に切り替わったとします。ネットワークの遅延により、ユーザーBのレスポンスが先に返り、その後に遅れていたユーザーAのレスポンスが返ってきた場合どうなるでしょうか?
上記のコードでは `isCancelled` フラグを用いているため、古いレスポンスによる状態の巻き戻し(Stale State)はある程度防げますが、「依存配列のプリミティブ値が変わるたびに、非同期のネットワークパイプラインとメモリ上のクリーンアップが走るコスト」 は無視できません。
—
3. パフォーマンスを極限まで高めるためのアーキテクチャ戦略
では、堅牢で無駄のないWebアプリケーションを構築するために、上級エンジニアとしてどのようなアプローチを取るべきでしょうか。
戦略1: プリミティブ値の「生成元」をコントロールする
バグの根源は `useEffect` の中にあるのではなく、大抵の場合 「親から子へ渡される値の不安定さ」 にあります。親コンポーネント側で、不要なオブジェクトやプリミティブの再生成(あるいは型変換のインライン記述)を徹底的に排除します。
// ❌ アンチパターン: レンダリングのたびに新しいプリミティブ(値の評価)が生まれる可能性
// ✅ ベストプラクティス: useMemoや事前計算で値を安定させる
const memoizedUserId = useMemo(() => Number(query.id), [query.id]);
const isEditable = useMemo(() => user.role === ‘ADMIN’, [user.role]);
(※注: プリミティブ値に対する `useMemo` は過剰最適化に見えるかもしれませんが、「型変換のコストを抑える」「参照の安定性を担保して子への無駄な伝播を防ぐ」という文脈においては非常に有効です。)
戦略2: 依存配列の「粒度」を疑い、不要な再実行を削ぎ落とす
本当にそのプリミティブ値の変化と、`useEffect` 内の処理は1:1で同期すべきでしょうか?
例えば、「ユーザーIDが変わったときだけフェッチしたい」のに、UIのトグル状態である `isEditable` が同じ依存配列に入っていると、ユーザーが編集モードを切り替えるたびにAPIリクエストが走ってしまいます。これは明確な設計ミスです。
// ❌ 混ぜるな危険:取得処理(Fetch)とUIの状態(isEditable)が結合している
useEffect(() => {
fetchData(userId);
}, [userId, isEditable]); // isEditableが変わるたびにフェッチが走る!
// ✅ 分離する:関心の分離(Separation of Concerns)
useEffect(() => {
// データの取得はuserIdにのみ依存させる
fetchData(userId);
}, [userId]);
// isEditableに起因する処理は別のエフェクト、あるいはイベントハンドラへ逃がす
戦略3: useRefを活用した「再実行したくない値」の制御
プリミティブ値であっても、「値が変わったときに `useEffect` をトリガーしたくはないが、最新の値は参照したい」というシーンが実務では多々あります。例えば、ロギングやアナリティクスの送信、あるいはイベントの最新状態のトラッキングです。
このような場合は、`useRef` を使ってプリミティブ値を保持します。`useRef` の `.current` を書き換えても再レンダリングは発生せず、`useEffect` のトリガーにもなりません。
import { useState, useEffect, useRef } from ‘react’;
export const AnalyticsTracker: React.FC<{ trackingId: string }> = ({ trackingId }) => {
const [count, setCount] = useState(0);
// trackingIdの最新値を保持するが、依存配列には入れない
const trackingIdRef = useRef(trackingId);
trackingIdRef.current = trackingId;
useEffect(() => {
// マウント時の処理や、countの変化に連動させたい処理
console.log(`Current tracking ID at effect start: ${trackingIdRef.current}`);
// 定期的なポーリングなど
const interval = setInterval(() => {
// 常に最新の trackingId を参照できる(クロージャの古い参照を防ぐ)
sendPing(trackingIdRef.current, count);
}, 5000);
return () => clearInterval(interval);
}, [count]); // trackingIdの変化ではエフェクトを再マウントさせない
return (
);
};
このパターンは、非同期処理の中で「最新のpropsやstateを安全にキャプチャしつつ、エフェクトのライフサイクルを無駄にリセットしたくない」という極めて実用的な現場の要求に対する、美しく洗練されたソリューションです。
—
まとめ:プリミティブ値だからと油断するな
Reactにおけるプリミティブ値の依存配列による制御は、一見すると最もシンプルに見えます。しかし、だからこそ:
1. 親からの値の渡し方が不安定になっていないか(`Object.is` が `false` になる罠)
2. 本当にそのプリミティブ値の変更に副作用を同期させるべきか(関心の結合のバグ)
3. エフェクトの再実行コストと非同期処理の競合リスク
これらを深く理解し、アーキテクチャレベルでコントロールできているかどうかが、ジュニアから真の上級エンジニアを分かつ分水嶺となります。
「動くからよし」とするコードベースから脱却し、ブラウザのメモリ効率とReactのFiberの挙動までを脳内でコンパイルしながらコードを書く。その圧倒的なこだわりこそが、最高峰のフロントエンド体験を支えるのです。
さあ、あなたのコードベースの依存配列を見直しに行きましょう。そこには、まだ削れる無駄と、潜んでいたバグが眠っているはずです。

コメント