【テクニカル・上級編】 useRefを依存配列に含めるべきか否か – React実践ガイド

useRefをuseEffectの依存配列に含めるべきか否か:Reactアーキテクチャの深淵を覗く

ReactのフックAPIは、コンポーネントにおける状態管理と副作用のロジックを劇的にシンプルにしました。特に`useEffect`は、コンポーネントのライフサイクルと外部システムとの同期を司る要であり、その依存配列の制御は、堅牢かつパフォーマンスの高いアプリケーションを構築する上で避けては通れない、奥深くも泥臭いテーマです。

今日、我々が深く掘り下げるのは、`useRef`を`useEffect`の依存配列に含めるべきか否か、という一見単純に見える問いの裏に潜む、Reactのレンダリング機構、JavaScriptのクロージャ、そしてメモリ効率といったアーキテクチャレベルの課題です。これは、単なる構文のルールではなく、フレームワークの内部挙動を理解し、意図的にコードを設計するための試金石となるでしょう。

`useRef`の本質:ミュータブルな永続コンテナ

まず、`useRef`の基本的な性質を再確認しましょう。`useRef`は、コンポーネントのライフサイクルを通じて不変のオブジェクトを返します。このオブジェクトは`.current`プロパティを持ち、その値はミュータブル(可変)です。そして、最も重要な特性は、`.current`プロパティの値を変更しても、コンポーネントの再レンダリングをトリガーしないという点にあります。

これは`useState`が返すステート変数とは対照的です。ステート変数を更新すれば再レンダリングがキューに入れられ、UIが同期されます。しかし`useRef`は、レンダーサイクルとは独立した、コンポーネントインスタンスに紐づく「秘密の引き出し」のようなものです。その中に何をしまおうと、ReactはUIの更新を気にしません。

この特性こそが、`useRef`を`useEffect`の依存配列に含めるべきか否かという議論の核心に直結します。

`useRef`オブジェクトを依存配列に含める場合:その挙動と真実

さて、`useEffect`の依存配列に`useRef`が返すオブジェクトそのもの(例えば`myRef`)を含めた場合、何が起こるでしょうか。

const myRef = useRef(null);

useEffect(() => {
// myRef.current を使った処理…
}, [myRef]); // myRef オブジェクトを依存配列に含める

Reactの依存配列の比較はシャロー比較(浅い比較)です。つまり、配列内の各要素が前回のレンダー時と同じ参照を持つかどうかをチェックします。`useRef`が返すオブジェクトは、コンポーネントのライフサイクルを通じて常に同じ参照を返します。

したがって、`myRef`を依存配列に含めても、`myRef`オブジェクト自体の参照が変わることは決してありません。結果として、この`useEffect`はコンポーネントのマウント時とアンマウント時(クリーンアップ時)にしか実行されません。 依存配列に`[]`を指定した場合と、実質的に同じ挙動になります。

これは、`useRef`を依存配列に含めること自体が無意味である、という意味ではありません。むしろ、意図的に「このエフェクトはコンポーネントの初期化時に一度だけ実行されればよいが、そのエフェクト内で`myRef`を参照する」という状況を明示する、という意味合いで使われることがあります。しかし、それは稀なケースであり、通常は`myRef`そのものを依存配列に含める必要はありません。

`useRef.current`を依存配列に含める場合:見過ごされがちな落とし穴

では、`myRef.current`を依存配列に含めた場合はどうでしょうか。

const myRef = useRef(0); // 例えばカウンターの値として使う

useEffect(() => {
console.log(‘Effect executed:’, myRef.current);
}, [myRef.current]); // myRef.current を依存配列に含める

多くの開発者がここで躓きます。`myRef.current`の値はコンポーネントのレンダーサイクルとは独立して変更され得ます。しかし、`useEffect`の依存配列の評価は、コンポーネントのレンダー時に行われます。

`myRef.current`の値を変更しても、それ自体は再レンダリングをトリガーしません。再レンダリングが起こらなければ、`useEffect`の依存配列は再評価されず、結果として`myRef.current`が変更されたとしても、その変更がエフェクトの再実行をトリガーすることはありません。エフェクトは、他の何らかの依存が変更され、再レンダリングが起こった時に初めて、その時点の`myRef.current`の値を評価します。

これは、多くの開発者が`useRef`を`useState`の代替として使い、その変更にエフェクトが反応すると期待して陥る、重大なバグの温床となり得ます。エフェクトが古い`myRef.current`の値に基づいた処理を実行し続けたり、期待するタイミングで再実行されないために、UIと内部状態の不整合や、外部システムとの同期の失敗を引き起こす可能性があります。

アーキテクチャ的考察:なぜ`useRef.current`は依存配列の友人ではないのか

この挙動は、Reactの設計思想、特に「コンポーネントはステートとプロップスに基づいてUIを宣言的に記述する」という原則に深く根ざしています。

1. 宣言的UIの原則: `useEffect`は、宣言的に記述されたUIと外部システムとの「同期」のために存在します。同期のトリガーは、主にステートやプロップスの変更、つまり再レンダリングによってもたらされるべきです。`useRef.current`の変更は、この「宣言的」なフローの外部で発生します。

2. パフォーマンスと差分検出: Reactは、前回のレンダーと今回のレンダーでの依存配列を比較し、変化があればエフェクトを再実行します。この比較は、再レンダリング時に行われるコストの低いシャロー比較です。`useRef.current`の変更が再レンダリングをトリガーしない以上、Reactはその変更を検知する機会がないのです。もし`useRef.current`の変更がエフェクトをトリガーするとすれば、Reactはミュータブルな参照の変更を常時監視する必要があり、それはフレームワークの根幹を揺るがすほどのパフォーマンスオーバーヘッドを引き起こすでしょう。

3. 古いクロージャ問題の回避: `useEffect`の依存配列は、エフェクト内部で参照される全てのリアクティブな値(ステート、プロップス、コンポーネント内で定義された関数など)を網羅することで、「古いクロージャ問題(Stale Closure Problem)」を回避する役割を担います。`useRef.current`は、このリアクティブな値の範疇には通常含まれません。もし`useRef.current`の変更に反応したいのであれば、それは通常、`useState`を使用するか、あるいはカスタムフック内で`useRef`と`useState`を組み合わせて「`ref.current`の変化をリアクティブなステートとして昇格させる」といった、より明示的なアプローチを取るべきです。

では、`useRef`はいつ、どのように真価を発揮するのか?

`useRef`は、依存配列に含めないことでこそ、その真価を発揮します。その利用は、以下のシナリオで輝きます。

1. エフェクト内部で、エフェクトの再実行をトリガーしないデータとして保持する

最も一般的なユースケースは、エフェクトが管理する外部リソース(タイマーID、WebSocketインスタンス、DOM要素など)を保持することです。これらのリソースは、エフェクトのライフサイクル内で初期化され、クリーンアップされるべきですが、その`current`値の変更がエフェクト自体の再実行をトリガーする必要はありません。

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

function Timer() {
const [count, setCount] = useState(0);
const intervalIdRef = useRef(null); // タイマーIDを保持するためのref

useEffect(() => {
// エフェクトがマウントされた時にタイマーを開始
intervalIdRef.current = setInterval(() => {
setCount(prevCount => prevCount + 1);
}, 1000);

// クリーンアップ関数でタイマーを停止
return () => {
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
}
};
}, []); // 依存配列が空なので、マウント時に一度だけ実行される
// intervalIdRef.current の変更はエフェクトの再実行をトリガーしない
// そして、トリガーする必要もない

return (

Timer: {count}

このタイマーは、useRefを使ってclearIntervalのIDを保持しています。

intervalIdRef自体は依存配列に含まれていません。

);
}

2. 古いクロージャ問題 (Stale Closures) の回避策として、最新のステートやプロップスを保持する

これは、`useRef`の最も強力で、かつ誤解されやすい利用法の一つです。特定のイベントリスナーやコールバック関数が、自身のクロージャ内に閉じ込められた「古い」ステートやプロップスを参照してしまう問題を回避するために使われます。

例えば、`setInterval`のコールバック関数が、常に最新のステートを参照したいが、`setInterval`自体は一度だけセットアップしたい場合などです。

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

function LatestStateViewer() {
const [count, setCount] = useState(0);
const latestCountRef = useRef(count); // 最新のcount値を保持するref

useEffect(() => {
// countが更新されるたびに、latestCountRef.currentも最新値に更新する
latestCountRef.current = count;
}, [count]); // countが変更されたら、このエフェクトが再実行され、latestCountRef.currentが更新される

useEffect(() => {
// このタイマーは一度だけセットアップされる(依存配列が空)
const id = setInterval(() => {
// 古いクロージャの問題を回避し、常に最新のcount値を参照できる
console.log(‘Interval triggered. Latest count:’, latestCountRef.current);
}, 2000);

return () => clearInterval(id);
}, []); // 依存配列が空なので、latestCountRef.current の変更はエフェクトの再実行をトリガーしない
// しかし、latestCountRef.current は常に最新の count を保持している
// これが useReducer の dispatch やコールバック関数を永続化するユースケースにも応用される

return (

Count: {count}

2秒ごとにコンソールに最新のカウント値が表示されます。

useEffectの依存配列が空でも、latestCountRef.currentは常に最新です。

);
}

この例では、`useEffect([count])`で`latestCountRef.current`を常に最新の`count`に同期させています。そして、別の`useEffect([])`でセットアップされた`setInterval`のコールバックは、`latestCountRef.current`を参照することで、自身が閉じ込められたクロージャ内の古い`count`値ではなく、常に最新の`count`値にアクセスできます。`latestCountRef`オブジェクト自体は依存配列に含める必要はなく、むしろ含めないことでエフェクトの不必要な再実行を防ぎ、パフォーマンスを最適化しています。

3. DOM要素への直接アクセス

`useRef`の最も古典的な用途の一つです。特定のDOM要素への参照を保持し、`useEffect`内でその要素に対して直接操作を行う場合に利用します。この際も、`ref`オブジェクト自体を依存配列に含める必要はほとんどありません。

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

function FocusInput() {
const inputRef = useRef(null); // input要素への参照を保持するref

useEffect(() => {
// コンポーネントがマウントされたらinput要素にフォーカス
if (inputRef.current) {
inputRef.current.focus();
}
}, []); // 依存配列が空なので、マウント時に一度だけ実行される
// inputRef は依存配列に含める必要がない
// inputRef.current が設定されるのはレンダリング後なので、エフェクト内で安全にアクセスできる

return (

ページが読み込まれたら自動的にフォーカスされる入力フィールド:

);
}

まとめ:意図的な設計とReactの哲学

`useRef`を`useEffect`の依存配列に含めるべきか否かという問いは、Reactの根底にある「宣言的UI」と「副作用の分離」という哲学を深く理解しているかを試すものです。

  • `useRef`オブジェクト自体を依存配列に含めることは、通常不要です。 `useRef`が返すオブジェクトの参照は常に同じであるため、エフェクトが再実行されることはありません。含めても害はありませんが、冗長であり、コードの意図を曖昧にする可能性があります。
  • `useRef.current`を依存配列に含めることは、ほとんどの場合 誤りであり、意図しないバグを引き起こす可能性があります。 `ref.current`の変更は再レンダリングをトリガーしないため、Reactはエフェクトを再実行する機会を得られません。
  • `useRef`は、コンポーネントのライフサイクルを超えて永続するミュータブルな参照として、あるいは古いクロージャ問題を回避し、エフェクトやコールバック内で最新の値を参照するための手段として真価を発揮します。その際、`useRef`オブジェクトや`useRef.current`を依存配列に含める必要は、ほとんどありません。

Reactのフックは強力ですが、その内部挙動と設計思想を深く理解しなければ、意図しない挙動やバグに悩まされることになります。`useRef`は、レンダーサイクルの外側でミュータブルな状態を管理するための、しかし非常に強力なツールです。その力を最大限に引き出すためには、依存配列の厳格な管理と、ステートと参照の役割分担を明確にすることが不可欠なのです。

上級エンジニアの皆さんが、この深掘りを通じて、より堅牢でパフォーマンスの高いReactアプリケーションの設計に役立つ知見を得られたならば幸いです。Reactの泥臭い魅力は、まさにこのような細部に宿っているのですから。

コメント

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