お前たち、また一つ、Reactの深淵を覗き込む時が来たぞ。
今日は、現場で若手エンジニアたちが「あれ?これってどうなんだっけ?」と首を傾げがちな、だけど一度理解すれば「なるほど、そういうことか!」と膝を打つようなテーマを掘り下げていこう。そう、`useRef` と `useEffect`、この二つの強力なフックが交錯する場所、「`useRef` を `useEffect` の依存配列に含めるべきか否か」 だ。
一見すると些細な疑問に見えるかもしれないが、ここにはReactの設計思想、コンポーネントのライフサイクル、そして副作用の制御という、フロントエンド開発の根幹をなす概念が凝縮されている。公式ドキュメントには書かれていない、あるいはサラッと流されてしまうような、現場の泥臭い知見を共有するぞ。
—
序章:フックの役割を再確認しようぜ、お前たち
まず、この議論に入る前に、`useEffect`と`useRef`、それぞれのフックがReactの世界でどのような役割を担っているのか、その本質を改めて確認しておこう。これが、今日の話の基礎体力となるからな。
`useEffect`:Reactの世界と外の世界の橋渡し役
`useEffect`は、Reactコンポーネントのレンダリング結果とは直接関係のない「副作用」を扱うためのフックだ。データフェッチ、DOMの直接操作、イベントリスナーの設定、タイマーの管理など、Reactの宣言的な世界の外で行われる操作を、コンポーネントのライフサイクルに合わせて安全に実行・クリーンアップする役目を担っている。
そして、その実行タイミングを制御するのが、おなじみ依存配列(Dependency Array) だ。この配列に含められた値が、前回のレンダリング時と比べて変更された場合にのみ、`useEffect`のコールバック関数が再実行される。これによって、無駄な副作用の実行を防ぎ、パフォーマンスを最適化しているわけだ。
`useRef`:レンダリングに縛られないミュータブルな箱
一方、`useRef` は、DOM要素への参照を取得するだけでなく、コンポーネントの再レンダリングをトリガーすることなく、ミュータブルな(変更可能な)値をコンポーネントのライフサイクルを通じて保持するために使われるフックだ。
「ミュータブル」というのがミソだぞ。`useRef`が返すオブジェクトは、`{ current: initialValue }`のような形をしている。この`current`プロパティの値を直接変更しても、Reactは再レンダリングをトリガーしない。これは、`useState`が返すステート変数とは根本的に異なる点だ。`useRef`は、Reactのレンダリングサイクルから一歩引いたところで、裏方としてデータを保持する、そんなイメージだな。
本題突入:`useRef`を依存配列に含めるべきか否か?
さあ、ここからが本番だ。この二つのフックの性質を踏まえた上で、本日のテーマに切り込むぞ。
結論から言おう。基本的には、`useRef`が返すオブジェクト(`ref`変数そのもの)も、その`current`プロパティの値も、`useEffect`の依存配列に含めるべきではない。
「なんでだ!?」と思ったお前、それでいい。その疑問こそが、成長の種だからな。では、その理由を深く掘り下げていこう。
理由1:`useRef`オブジェクトはレンダリング間で安定している
まず、`useRef`フックが返す`ref`オブジェクト(例えば、`const myRef = useRef(null);` の`myRef`)自体について考えてみよう。
Reactは、コンポーネントが初めてレンダリングされる際に`useRef`を呼び出し、一度`ref`オブジェクトを作成する。その後、コンポーネントが何回再レンダリングされようとも、常に同じ`ref`オブジェクトのインスタンスを返す。
これが何を意味するか?もし`myRef`を`useEffect`の依存配列に含めたとしても、その参照はコンポーネントのライフサイクルを通じて変わらないため、`useEffect`は一度も再実行されないことになる。つまり、依存配列に入れても全く意味がない。無駄な記述になるだけだ。
import React, { useEffect, useRef } from ‘react’;
function MyComponent() {
const stableRef = useRef(0); // このmyRefオブジェクト自体は、コンポーネントのライフサイクルを通じて常に同じインスタンスを指す
useEffect(() => {
// このエフェクトは初回レンダリング時のみ実行される
// stableRefが依存配列に含まれていても、その参照は変わらないため、
// コンポーネントが再レンダリングされてもこのエフェクトは再実行されない
console.log(‘useEffect (stableRef in deps):’, stableRef.current);
}, [stableRef]); // stableRefを依存配列に含めても意味がない
useEffect(() => {
// こちらのエフェクトは、依存配列が空なので初回レンダリング時のみ実行される
console.log(‘useEffect (empty deps):’, stableRef.current);
}, []); // 依存配列が空の場合と同じ挙動
return (
Ref value: {stableRef.current}
{/ stableRef.currentを更新しても再レンダリングはされない /}
);
}
理由2:`ref.current`の変更は再レンダリングをトリガーしない
ここが最も重要で、多くの開発者が誤解しやすいポイントだ。
`ref.current`プロパティの値はミュータブルであり、いつでも直接変更できる。しかし、前述した通り、その変更はReactの再レンダリングをトリガーしない。
`useEffect`の依存配列は、Reactが「このコンポーネントが再レンダリングされた際に、依存する何かの値が変わったか?」を判断するためのメカニズムだ。Reactは、再レンダリングのたびに依存配列内の各値を前回の値と比較し、変更があればエフェクトを再実行する。
だが、`ref.current`の変更は再レンダリング自体をトリガーしないため、Reactはその変更を「知らない」。つまり、依存配列に`ref.current`を含めたとしても、`ref.current`が内部で変更されても、Reactはその変更を検知できず、`useEffect`は再実行されないのだ。
「え、じゃあもし`ref.current`が変わったらエフェクトを動かしたい場合はどうすればいいんだ?」と思うかもしれない。それはな、そもそも`ref.current`でその値を管理するべきではない、というReactからのメッセージなんだよ。
もし値の変更に応じてUIの更新や副作用の実行をトリガーしたいなら、それは`useState`や`useReducer`で管理すべき「状態」なんだ。`useRef`は、あくまで「レンダリングに関係なく、コンポーネントの寿命を通じて値を保持したい」ときに使うべきものだ。
現場のリアルな落とし穴:見えないバグの温床
この「`ref.current`を依存配列に入れても意味がない」という事実を知らないと、現場で以下のようなバグを生み出す可能性がある。
1. 「あれ?エフェクトが動かないぞ?」
- `ref.current`に外部ライブラリのインスタンスなどを保持し、そのインスタンスの設定を`ref.current`の値に応じて変更したい。
- `ref.current`を依存配列に入れたものの、いざ`ref.current`の値を変更してもエフェクトが再実行されず、意図した挙動にならない。
- 結果、デバッグに時間を浪費し、「Reactってよくわからん!」と嘆くことになる。
2. 「余計な依存を追加してしまっている」
- 本来は`ref.current`の値を読み取るだけで良いのに、深く考えずに依存配列に入れてしまう。
- これは直接的なバグには繋がりにくいが、コードの可読性を下げ、依存関係を不必要に複雑にする。将来的に他の開発者が「なぜここに`ref.current`が入っているんだろう?」と混乱する元になる。
具体的なシナリオとコード例で理解を深めよう
ここまで理屈をこねてきたが、やはりコードを見ないとピンとこないだろう。具体的な例で、正しい使い方と間違った使い方を見ていこう。
シナリオ1:`ref.current`の値をエフェクト内で読み込むだけの場合(正しい使い方)
これはよくあるパターンだ。例えば、サードパーティの地図ライブラリのインスタンスを`ref`に保持し、親コンポーネントから渡された`zoomLevel`プロパティの変更に応じて地図のズームレベルを更新する場合など。
この場合、`useEffect`は`zoomLevel`の変更にのみ反応すればよく、`mapRef.current`(地図インスタンス)はエフェクト内で単に「使われる」だけだ。
import React, { useEffect, useRef } from ‘react’;
// 擬似的な外部地図ライブラリのクラス
class Map {
constructor(container) {
this.container = container;
this.zoom = 10;
console.log(‘Map instance created in:’, container);
}
setZoom(level) {
this.zoom = level;
console.log(`Map zoom set to: ${this.zoom}`);
}
destroy() {
console.log(‘Map instance destroyed.’);
}
}
function MapViewer({ zoomLevel }) {
const mapContainerRef = useRef(null); // DOM要素への参照を保持
const mapInstanceRef = useRef(null); // Mapライブラリのインスタンスを保持
useEffect(() => {
// マウント時に地図インスタンスを作成
if (mapContainerRef.current && !mapInstanceRef.current) {
mapInstanceRef.current = new Map(mapContainerRef.current);
}
// クリーンアップ関数でインスタンスを破棄
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.destroy();
mapInstanceRef.current = null; // 参照をクリア
}
};
}, []); // 依存配列は空。マウント時とアンマウント時のみ実行
useEffect(() => {
// zoomLevelが変更された場合にのみ、地図インスタンスのズームを更新
// mapInstanceRef.current は依存配列に含めない。
// その値の変更を監視したいわけではなく、現在の値を「利用」したいだけだからだ。
if (mapInstanceRef.current) {
mapInstanceRef.current.setZoom(zoomLevel);
}
}, [zoomLevel]); // zoomLevelの変更にのみ反応
return (
Map Viewer (Zoom: {zoomLevel})
);
}
// 親コンポーネントでズームレベルを操作する例
export default function App() {
const [currentZoom, setCurrentZoom] = React.useState(10);
return (
);
}
この例では、`mapInstanceRef.current`は`useEffect`の内部で参照されていますが、依存配列には含まれていません。なぜなら、`mapInstanceRef.current`の値(地図インスタンス自体)は、`zoomLevel`が変更されるたびに新しいものに置き換わるわけではなく、一度生成されたものがずっと使われ続けるからです。`useEffect`は`zoomLevel`の変化だけを検知し、そのたびに既存の`mapInstanceRef.current`を使って`setZoom`を呼び出す、という流れだ。
シナリオ2:`ref.current`を依存配列に含めてしまった場合の挙動(間違った使い方)
これは、`ref.current`の変更を`useEffect`で監視しようとする、典型的な誤解の例だ。
import React, { useEffect, useRef, useState } from ‘react’;
function CounterWithRef() {
const countRef = useRef(0); // カウンターの値をrefで保持
const [_, forceRender] = useState(0); // 強制再レンダリング用(デモ目的)
useEffect(() => {
// countRef.currentが変化したときに、このエフェクトが再実行されることを期待する
// しかし、実際にはそうはならない!
console.log(‘useEffect triggered! Current ref count:’, countRef.current);
}, [countRef.current]); // <<< ここが問題!ref.currentを依存配列に入れても意味がない
const increment = () => {
countRef.current++;
console.log(‘Ref count incremented:’, countRef.current);
// countRef.currentは変更されたが、再レンダリングはトリガーされない。
// そのため、UIも更新されないし、useEffectも再実行されない。
};
const incrementAndRender = () => {
countRef.current++;
console.log(‘Ref count incremented and re-render:’, countRef.current);
forceRender(prev => prev + 1); // ここで強制的にコンポーネントを再レンダリング
// 再レンダリングされても、useEffectはcountRef.currentの「前の値からの変化」を検知できない
};
return (
Ref Counter Demonstration
Ref count: {countRef.current}
{/ UIはref.currentの更新に追従しない /}
(コンソールログに注目してください)
);
}
export default CounterWithRef;
このコードを実行し、”Increment Ref (no re-render)” ボタンをクリックしても、コンソールには`useEffect`がトリガーされたというログは出ないはずだ。なぜなら、`countRef.current`は変更されたが、それが再レンダリングをトリガーしないため、Reactが依存配列を再評価する機会がないからだ。
“Increment Ref & Force Re-render” ボタンをクリックすると、コンポーネントは再レンダリングされる。しかし、それでも`useEffect`は「初回のみ」しかログを出さないはずだ。これは、Reactが依存配列の比較を行う際に、`ref.current`のミュータブルな変更を検知できない(追跡対象外)ためだ。たとえ再レンダリングされても、`ref.current`の「変更履歴」はReactの監視下にないんだ。
じゃあ、`ref.current`の値が変更されたら何かしたい場合はどうするんだ?
答えはシンプルだ。`useState`を使え。
値の変更がコンポーネントのロジックやUIに影響を与えるべきならば、それは「状態」として`useState`で管理するべきだ。
import React, { useEffect, useState } from ‘react’;
function CounterWithState() {
const [count, setCount] = useState(0); // カウンターの値をstateで保持
useEffect(() => {
// countが変化したときに、このエフェクトが再実行される
console.log(‘useEffect triggered! Current state count:’, count);
}, [count]); // countはstate変数なので、その変更は再レンダリングをトリガーし、useEffectも再実行される
const increment = () => {
setCount(prev => prev + 1);
console.log(‘State count incremented:’, count + 1); // 更新直後の値はここで+1しないと見えない
};
return (
State Counter Demonstration
State count: {count}
{/ UIはstateの更新に追従する /}
(コンソールログに注目してください)
);
}
export default CounterWithState;
これなら期待通りに動くだろう? `useState`で管理された`count`が変更されるたびにコンポーネントが再レンダリングされ、`useEffect`の依存配列に含まれる`count`が変化したと検知され、エフェクトが再実行される。これがReactの基本的な「状態管理と副作用」のサイクルだ。
ブラウザの裏側とReactの最適化:参照等価性の重要性
もう少し深く掘り下げてみよう。Reactが`useEffect`の依存配列の変更をどのように検知しているか、その裏側にある原理についてだ。
Reactは、依存配列内の各値を、前回のレンダリング時に渡された値と参照等価性(Reference Equality) で比較している。つまり、`===`演算子を使って比較しているんだ。
- プリミティブ型(数値、文字列、真偽値など): 値そのものが比較される。`5 === 5`は`true`、`”hello” === “world”`は`false`。
- オブジェクト型(オブジェクト、配列、関数、Refオブジェクトなど): メモリ上の参照(アドレス)が比較される。全く同じ内容のオブジェクトでも、メモリ上の異なる場所にあれば`{a:1} === {a:1}`は`false`になる。
`useRef`が返す`ref`オブジェクト自体は、コンポーネントのライフサイクルを通じて常に同じメモリ参照を指している。だから、依存配列に入れても`===`比較で常に`true`となり、エフェクトは再実行されない。
そして、`ref.current`の値は、その`ref`オブジェクトのプロパティとして存在する。`ref.current`を更新しても、`ref`オブジェクト自体の参照は変わらないし、Reactは`ref.current`プロパティの変更を特別に監視しているわけではない。`useEffect`の依存配列が期待するのは、Reactの再レンダリングサイクルに乗って「変化した」と検知される値なんだ。
だからこそ、`useRef`が返す`ref`オブジェクトや`ref.current`を依存配列に含めることは、Reactの設計思想と依存配列の比較メカニズムから見ても、ほとんど意味がないか、誤解を招くことになるんだ。
まとめと現場へのアドバイス:賢くフックを使いこなせ
お前たち、今日の話、しっかり頭に入ったか?
`useRef`を`useEffect`の依存配列に含めるべきか否か。この問いに対する答えは、Reactのフック設計哲学を理解していれば自然と導き出される。
- `useRef` は、Reactのレンダリングサイクルから独立した、ミュータブルな値を保持するための箱だ。その変更は再レンダリングをトリガーしない。
- `useEffect`の依存配列 は、コンポーネントの「状態」や「プロパティ」の変化(これらは再レンダリングをトリガーする)に応じて、副作用を再実行するためのメカニズムだ。
この二つの役割を混同するな。
もし`ref.current`の値が変更されたときに何か特定の副作用を実行したいのなら、それは`ref.current`で管理すべき値ではない可能性が高い。 その値は、`useState`や`useReducer`を使って「状態」として管理し、その状態変数を`useEffect`の依存配列に含めるべきだ。そうすれば、Reactは値の変更を検知し、適切に副作用を再実行してくれる。
`useRef`は、DOM要素への参照、タイマーID、外部ライブラリのインスタンスなど、コンポーネントのUI更新とは直接関係ないが、コンポーネントの寿命を通じて保持したいミュータブルなデータに使うのが、最もパワフルで安全な使い方だ。
現場でコードを書くときは、「この値は再レンダリングをトリガーすべきか?」「この値の変更はUIに影響を与えるか?」と自問自答してみろ。その答えが「No」なら`useRef`、「Yes」なら`useState`、これが基本的な判断基準だ。
Reactのフックは強力だが、その力を最大限に引き出すには、それぞれのフックがどのような思想で設計されているのかを深く理解することが不可欠だ。今日の話が、お前たちのReact理解のさらなる一助となれば幸いだ。
さあ、賢くフックを使いこなし、最高のフロントエンド体験をユーザーに届けようぜ! 現場からは以上だ!

コメント