【実務・中級編】 プリミティブ値の依存配列による制御 – React実践ガイド

お疲れ。最近、君が書いたコンポーネントのコードレビューをしたんだけどさ……正直、「あ、これまたやってるな」って箇所がいくつかあったんだよね。

特に、`useEffect` の依存配列(dependency array)まわり。
「とりあえず値が変わったっぽかったから、プリミティブ値だし突っ込んでおいた」みたいな実装が見受けられた。動くには動くけどさ、それ、本当に意図した通りのライフサイクルで動いてる? 無駄な再レンダリングやエフェクトの暴発を引き起こしてないかい?

今日は、Reactの心臓部である「プリミティブ値による依存配列の制御」について、ブラウザの裏側の動きも含めて徹底的に叩き込む。中級から一段上のシニアへステップアップするための必須知識だから、しっかりついてきてほしい。

—

1. 依存配列の判定基準:Reactは裏側で何をしているのか?

まず大前提として、`useEffect` や `useMemo`、`useCallback` の依存配列に指定した値が「変わったかどうか」を、Reactはどうやって判定していると思う?

「そりゃ、比較してるんでしょ」――その通り。だけど、その比較アルゴリズムの正体を知っている人は意外と少ない。
Reactは、依存配列の比較に `Object.is()` というJavaScriptの標準仕様を使っている。

`Object.is()` とは何か?

ざっくり言うと、`===`(厳密等価演算子)の厳格版だ。基本的には `===` と同じ挙動をするんだけど、主に以下の2点で `===` と異なる。

1. `Object.is(NaN, NaN)` が `true` になる(`===` だと `false` になる)
2. `Object.is(+0, -0)` が `false` になる(`===` だと `true` になる)

まあ、Reactのコンテキストで重要なのは 1 の `NaN` の話よりも、「数値(number)、文字列(string)、真偽値(boolean)といったプリミティブ値は、値そのものが同じであれば、メモリ上のどこにあろうとも同一とみなされる」 という点だ。

プリミティブ値なら安心して依存配列に突っ込んでいいのか?

「じゃあ、プリミティブ値なら何度書き換えても安全だし、依存配列にポンポン入れていいんですね?」
……結論から言うと、半分正解で、半分は大間違いだ。

プリミティブ値自体の比較は高速だし、値が変化した時(`Object.is()` が `false` を返す時)にだけエフェクトが走る仕組みは非常に美しくできている。
問題なのは、「そのプリミティブ値が、本当に『エフェクトを再実行すべきトリガー』としてふさわしいか?」 という設計の文脈のほうなんだよ。

—

2. 現場でやりがちな「プリミティブ値のアンチパターン」

よくある現場の失敗例を見せよう。
例えば、親から渡されたIDやフラグをもとに、データフェッチを行うコンポーネントがあったとする。

// 【アンチパターン】よくある現場のコード
import React, { useState, useEffect } from ‘react’;

type UserProfileProps = {
userId: string;
debugMode: boolean; // 開発用のフラグなど
};

export const UserProfile: React.FC = ({ userId, debugMode }) => {
const [userData, setUserData] = useState(null);

// userIdが変わったらフェッチしたい、という意図
useEffect(() => {
let isMounted = true;

async function fetchUserData() {
console.log(`Fetching for user: ${userId}, Debug: ${debugMode}`);
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();

if (isMounted) {
setUserData(data);
}
}

fetchUserData();

return () => {
isMounted = false;
};
}, [userId, debugMode]); // ← ここ!debugModeを依存配列に入れてしまっている

return

{/ UIの描画 /}

;
};

さて、このコードの何が問題かわかるかい?
`debugMode` は確かにプリミティブ値(boolean)だし、`Object.is()` で安全に比較できる。
しかし、親コンポーネントの再レンダリングの拍子に、この `debugMode` の参照(あるいは親の都合での再描画)が絡んだり、あるいは「本来は `userId` が変わった時だけにフェッチしたいのに、`debugMode` がトグルされただけで不要なAPIリクエストが走る」というバグを生む温床になるんだ。

プリミティブ値だからといって、コンポーネント内のあらゆる状態やPropsを思考停止で依存配列に並べるのは、シニアとしては絶対に避けたいところだね。

—

3. 実践!堅牢なプリミティブ値の依存配列コントロール

じゃあ、実務でどう書くのが正解なのか。
クリーンアップ関数や依存配列の制御を完璧に盛り込んだ、現場でそのまま使える実用的なサンプルコードを用意した。じっくり見てほしい。

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

type ArticleViewerProps = {
articleId: number; // 記事のID(プリミティブ値:number)
autoSaveInterval: number; // 自動保存の間隔(秒)(プリミティブ値:number)
};

export const ArticleViewer: React.FC = ({
articleId,
autoSaveInterval,
}) => {
const [content, setContent] = useState(”);
const [statusMessage, setStatusMessage] = useState(”);

// 1. 記事IDの変化に追従するデータフェッチの副作用
useEffect(() => {
// 競合状態(Race Condition)を防ぐためのフラグ
let isCurrent = true;

const loadArticle = async () => {
setStatusMessage(‘読み込み中…’);
try {
// 実際のAPIコールを想定
const res = await fetch(`/api/articles/${articleId}`);
const data = await res.json();

// 非同期処理が完了した時点で、このエフェクトが破棄されていなければ状態を更新
if (isCurrent) {
setContent(data.content);
setStatusMessage(‘読み込み完了’);
}
} catch (error) {
if (isCurrent) {
setStatusMessage(‘読み込みに失敗しました’);
}
}
};

loadArticle();

// クリーンアップ関数:
// articleIdが変わる、またはアンマウントされた時に前の非同期処理の結果を無視する
return () => {
isCurrent = false;
};
}, [articleId]); // プリミティブ値である articleId のみが変化した時だけ再実行される

// 2. タイマーなどの継続的な副作用の制御
useEffect(() => {
// autoSaveInterval が 0 以下の場合はタイマーを起動しない安全策
if (autoSaveInterval <= 0) return; const timerId = setInterval(() => {
console.log(`[AutoSave] 記事ID: ${articleId} の下書きを保存中… (間隔: ${autoSaveInterval}秒)`);
// ここに自動保存のロジックが入る
}, autoSaveInterval 1000);

// クリーンアップ関数:
// インターバル中に autoSaveInterval や articleId が変わったら、古いタイマーを必ずクリアする
return () => {
clearInterval(timerId);
};
}, [articleId, autoSaveInterval]); // 関連するプリミティブ値を正確に指定

return (

記事ビュアー (ID: {articleId})

ステータス: {statusMessage}

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