useRefをマスターしてReactのパフォーマンスを改善する方法
導入:なぜ今、useRefなのか?
Reactは、コンポーネントベースの宣言的なUIライブラリとして、現代のフロントエンド開発に革命をもたらしました。その中心的な思想は「状態(State)が変われば、UIが自動的に更新される」というものです。この仕組みは非常に強力で直感的ですが、アプリケーションが複雑化するにつれて、パフォーマンスという壁に直面することがあります。特に、意図しない再レンダリングは、アプリケーションの応答性を著しく低下させる主要な原因の一つです。
ここで脚光を浴びるのが、Reactフックの中でも一見地味ながら、極めて強力なツールである useRef です。多くの開発者は useRef を「DOM要素にアクセスするためのもの」と認識していますが、その真のポテンシャルはそれだけにとどまりません。useRef の本質は「再レンダリングを引き起こさずに、コンポーネントのライフサイクルを越えて永続するミュータブル(変更可能)な値を保持する」能力にあります。
この「再レンダリングを引き起こさない」という特性こそが、パフォーマンス最適化の鍵を握っています。useState が状態の変更をUIに反映させるためのものであるのに対し、useRef はUIの更新とは無関係なデータを裏で静かに保持し続けることができます。
この記事では、useRef の基本的な概念から始め、useState との決定的な違いを明確にします。そして、本題であるパフォーマンス改善に焦点を当て、不要な再レンダリングの抑制、高コストな計算結果のキャッシュ、タイマー管理といった具体的なテクニックを、豊富なコード例とともに徹底的に解説します。さらに、useEffect や useCallback といった他のフックとの連携、forwardRef や useImperativeHandle を用いた高度なコンポーネント設計、そして useRef を使う上での注意点やアンチパターンまで、包括的に掘り下げていきます。
この記事を読み終える頃には、あなたは useRef を単なるDOMアクセスのためのツールとしてではなく、Reactアプリケーションのパフォーマンスを劇的に向上させるための戦略的な武器として使いこなせるようになっているはずです。さあ、useRef の奥深い世界へ旅立ちましょう。
第1章: useRefの基礎を理解する
パフォーマンス改善のテクニックを学ぶ前に、まずはuseRefが何であり、どのように機能するのか、その基本的なメカニズムをしっかりと理解することが不可欠です。
1.1. useRefとは何か?
useRef は、Reactが提供するフックの一つで、ミュータブルな値を保持するための「箱」のようなものだと考えることができます。このフックを呼び出すと、特定のプロパティを持つプレーンなJavaScriptオブジェクトが返されます。
“`javascript
import { useRef } from ‘react’;
function MyComponent() {
const myRef = useRef(initialValue);
// myRef は { current: initialValue } というオブジェクトになる
// …
}
“`
useRef が返すオブジェクトには、.current というただ一つのプロパティが存在します。この .current プロパティに、保持したい値を代入したり、保持している値を取得したりします。
javascript
myRef.current = "新しい値"; // 値の更新
console.log(myRef.current); // 値の取得
useRef の最も重要な特徴は、以下の2つです。
-
永続性:
useRefが返すオブジェクトは、コンポーネントが最初にマウントされてからアンマウントされるまでの間、常に同じインスタンスであり続けます。つまり、コンポーネントが再レンダリングされても、myRefという変数自体は毎回新しく生成されますが、それが指し示す{ current: ... }というオブジェクトは常に同一のものです。これにより、再レンダリングをまたいで値を保持することができます。 -
非トリガー性:
.currentプロパティの値を変更しても、Reactに再レンダリングを通知しません。これはuseRefの核心的な動作であり、useStateとの最大の違いです。
1.2. useStateとの決定的な違い
useRef を理解するためには、最も身近なフックである useState との比較が効果的です。両者はどちらも「値を保持する」という点では似ていますが、その目的と挙動は全く異なります。
最大の違いは「再レンダリングをトリガーするか否か」です。
useState: 状態を更新するためのセッター関数(例:setCount)を呼び出すと、Reactに再レンダリングをスケジュールするよう通知します。これは、UIに表示されている値を更新するための基本的なメカニズムです。useRef:.currentプロパティを直接書き換えても、Reactはそれを検知せず、再レンダリングは一切発生しません。
この違いを体感するために、簡単なカウンターをそれぞれで実装してみましょう。
useState を使ったカウンター:
“`jsx
import React, { useState } from ‘react’;
function StateCounter() {
const [count, setCount] = useState(0);
let renderCount = 0;
console.log(‘StateCounterがレンダリングされました’);
renderCount++;
const handleClick = () => {
setCount(count + 1);
};
return (
useStateカウンター
カウント: {count}
(このコンポーネントのレンダリング回数: {renderCount})
);
}
“`
このコンポーネントでは、ボタンをクリックするたびに setCount が呼ばれ、count の値が更新されます。その結果、コンポーネントが再レンダリングされ、画面上の「カウント」の表示が更新されます。コンソールには毎回 “StateCounterがレンダリングされました” と出力されるはずです。
useRef を使ったカウンター:
“`jsx
import React, { useRef } from ‘react’;
function RefCounter() {
const countRef = useRef(0);
let renderCount = 0;
console.log(‘RefCounterがレンダリングされました’);
renderCount++;
const handleClick = () => {
countRef.current = countRef.current + 1;
console.log(‘Refのカウント:’, countRef.current);
// UIは更新されない!
};
return (
useRefカウンター
{/ refの値を直接表示するのはアンチパターンだが、ここでは挙動を示すために表示 /}
現在のRefカウント (UIは更新されない): {countRef.current}
(このコンポーネントのレンダリング回数: {renderCount})
);
}
“`
こちらのコンポーネントでは、ボタンをクリックすると countRef.current の値は確かに増え、コンソールにも新しい値が出力されます。しかし、画面に表示されている「現在のRefカウント」の値は全く変わりません。なぜなら、.current の変更は再レンダリングをトリガーしないからです。コンポーネントが最初に表示されたときの 0 のままです。
この比較から、それぞれのユースケースが見えてきます。
useState: ユーザーに見えるUIの状態、変更が画面に即時反映されるべきデータに使用します。useRef: 変更がUIに直接関係しない値、再レンダリングを引き起こしたくない値を保持するために使用します。
1.3. useRefの主な2つの用途
useRef の使い道は、大きく分けて以下の2つに分類できます。
-
DOM要素への参照
これはuseRefの最も一般的でよく知られた使い方です。JSX要素のref属性にuseRefから作成した ref オブジェクトを渡すことで、そのDOMノードへの直接的な参照を保持できます。“`jsx
import React, { useRef, useEffect } from ‘react’;function TextInputWithFocusButton() {
const inputEl = useRef(null);useEffect(() => {
// コンポーネントがマウントされたらinput要素にフォーカスを当てる
inputEl.current.focus();
}, []); // 空の依存配列でマウント時に一度だけ実行const onButtonClick = () => {
// ボタンクリックでinput要素にフォーカスを当てる
inputEl.current.focus();
};return (
<>
);
}
``inputEl.current
この例では、は実際のDOM要素を指します。これにより、focus()やplay()、要素のサイズ取得(inputEl.current.getBoundingClientRect()`)など、標準のDOM APIを直接呼び出すことができます。これは、Reactの宣言的な世界から、命令的なDOM操作への「脱出口」として機能します。 -
再レンダリングをトリガーしないミュータブルな値の保持
こちらが、この記事の主題であるパフォーマンス改善に直結する使い方です。コンポーネントのライフサイクルを通じて、ある値を保持したいが、その値の変更がUIの再描画を必要としない場合に非常に役立ちます。具体的な例としては、以下のようなケースが挙げられます。
–setTimeoutやsetIntervalのタイマーID
– 前回のpropsやstateの値
– WebSocket のコネクションインスタンス
– アニメーションループの状態フラグ
– マウス座標やスクロール位置など、高頻度で更新されるがUIへの反映は間引きたいデータ
これらの値は、コンポーネントのロジックには必要ですが、UI自体を構成する要素ではありません。これらを useState で管理すると、値が更新されるたびに不要な再レンダリングが発生し、パフォーマンスの低下を招きます。useRef を使うことで、この無駄をなくすことができるのです。
第2章: useRefによるパフォーマンス改善の具体的なテクニック
useRef の基礎を固めたところで、いよいよ本題であるパフォーマンス改善の具体的なテクニックを見ていきましょう。ここでは、よくあるシナリオを例に、useRef をどのように活用して不要な再レンダリングを防ぎ、アプリケーションを高速化するかを解説します。
2.1. 不要な再レンダリングの防止
Reactアプリケーションで最も一般的なパフォーマンスのボトルネックは、過剰な再レンダリングです。useRef は、UIの更新を伴わない状態変化を管理することで、この問題を解決する強力な手段となります。
シナリオ: 高頻度で更新される値の管理 (マウス座標の追跡)
ユーザーのマウスの動きを追跡するコンポーネントを考えてみましょう。
useState を使った悪い例:
“`jsx
import React, { useState, useEffect } from ‘react’;
function MouseTrackerWithState() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (event) => {
// マウスが1px動くたびにstateが更新され、再レンダリングが走る!
setPosition({ x: event.clientX, y: event.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
console.log('イベントリスナーを追加');
return () => {
window.removeEventListener('mousemove', handleMouseMove);
console.log('イベントリスナーを削除');
};
}, []);
console.log(‘MouseTrackerWithStateがレンダリングされました’);
return (
useStateによるマウストラッカー
X: {position.x}, Y: {position.y}
);
}
``setPosition` が呼び出され、コンポーネントが猛烈な勢いで再レンダリングされます。コンソールを開いて確認すれば、ログが滝のように流れていくのがわかるでしょう。単純な表示なら問題ないかもしれませんが、このコンポーネントがより複雑な子コンポーネントを持っていた場合、アプリケーション全体が重くなる原因となります。
このコードは機能しますが、パフォーマンス的には大きな問題を抱えています。マウスが動くたびに
useRef を使った改善例:
では、もしマウス座標をUIに常時表示する必要がなく、「特定のタイミング(例: クリック時)で最新の座標を取得できれば良い」という要件だったらどうでしょうか。この場合、useRef が最適です。
“`jsx
import React, { useRef, useEffect } from ‘react’;
function MouseTrackerWithRef() {
const positionRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (event) => {
// refを更新しても再レンダリングは発生しない
positionRef.current = { x: event.clientX, y: event.clientY };
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
const showPosition = () => {
alert(現在のマウス位置: X=${positionRef.current.x}, Y=${positionRef.current.y});
};
console.log(‘MouseTrackerWithRefがレンダリングされました’); // 初回のみログが出力される
return (
useRefによるマウストラッカー
マウスを動かしてもこのコンポーネントは再レンダリングされません。
);
}
“`
この改善例では、マウスが動いても positionRef.current が更新されるだけで、再レンダリングは一切発生しません。コンソールログは初回レンダリング時に一度だけ表示されます。そして、ユーザーがボタンをクリックしたときに初めて、useRef に保存されている最新の座標が利用されます。
このように、「データは常に最新に保ちたいが、UIへの反映は特定のタイミングだけで良い」というケースにおいて、useRef は不要な再レンダリングを劇的に削減し、パフォーマンスを大幅に向上させます。スクロール位置の監視など、他の高頻度イベントでも同様のテクニックが有効です。
2.2. 高コストな計算結果のキャッシュ
コンポーネント内で重い計算処理やデータ整形を行う場合、その処理が再レンダリングのたびに実行されるとパフォーマンスの低下につながります。useMemo は依存配列の値が変わらない限り計算結果をメモ化(キャッシュ)するためのフックですが、useRef を使っても同様の、しかし少し異なる目的のキャッシュを実現できます。
シナリオ: コンポーネントのライフサイクルで一度だけ実行したい初期化処理
APIから取得した大規模なデータセットを、描画に適した形式に一度だけ変換したい、というケースを考えます。useMemo は依存配列にAPIデータを含めることになりますが、もし何らかの理由で再レンダリングが発生し、依存配列の参照が変わってしまうと再計算が走る可能性があります。コンポーネントが存在する限り、絶対に一度しか計算したくない場合は useRef が確実です。
“`jsx
import React, { useRef } from ‘react’;
// この関数は非常に重い計算をシミュレートする
const performExpensiveCalculation = (data) => {
console.log(‘非常に重い計算を実行中…’);
// (例) 100万回のループ
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += i;
}
return 計算結果: ${data} - ${sum};
};
function ExpensiveComponent({ data }) {
const calculationCacheRef = useRef(null);
// キャッシュが存在しない場合(初回レンダリング時)のみ計算を実行
if (calculationCacheRef.current === null) {
calculationCacheRef.current = performExpensiveCalculation(data);
}
// 再レンダリングされても、ifブロックはスキップされ、キャッシュされた値が使われる
console.log(‘ExpensiveComponentがレンダリングされました’);
return (
高コストな計算結果
{calculationCacheRef.current}
);
}
“`
このパターンでは、useRef を一種のインスタンス変数のように使用しています。calculationCacheRef.current が null の間(つまり初回レンダリング時)だけ高コストな計算が実行され、その結果が .current に保存されます。以降の再レンダリングでは、if文の条件が偽になるため、計算はスキップされ、キャッシュされた値が即座に返されます。
useMemo(fn, []) と似ていますが、useRef を使うこのアプローチは、より命令的で、コンポーネントのライフサイクルに密接に結びついた一度きりの初期化処理であることを明確に示したい場合に適しています。
2.3. 前回の値(PropsやState)の保持
ある prop や state が以前の値からどう変化したかを検出したい場合があります。例えば、「カウンターの値が増えたか減ったか」「ユーザー名が変更されたか」などを知りたいケースです。Reactにはこれを直接行うフックは用意されていませんが、useRef と useEffect を組み合わせることで、簡単に実現できます。
カスタムフック usePrevious の実装
このロジックは非常に再利用性が高いため、カスタムフックとして切り出すのが一般的です。
“`jsx
import { useEffect, useRef } from ‘react’;
function usePrevious(value) {
const ref = useRef();
// useEffectはレンダリングが完了した「後」に実行される
useEffect(() => {
// 現在の値をrefに保存する
ref.current = value;
}, [value]); // valueが変更されるたびにrefを更新
// useEffectが実行される前の、前回のレンダリング時の値を返す
return ref.current;
}
``usePrevious
このフックの動作は巧妙です。
1. コンポーネントがレンダリングされる際、フックが呼ばれます。この時点ではref.currentは **前回のレンダリング時** の値を保持しています。その値が返されます。useEffect
2. レンダリングが完了し、画面が描画されます。
3. その後、のコールバック関数が実行されます。useEffect
4.の中で、ref.currentが **今回のレンダリング時** のvalueで更新されます。ref.current` にはステップ4で保存された値が入っているため、「前回の値」として正しく返されるわけです。
5. 次の再レンダリングが起こると、1. に戻ります。この時
usePrevious の使用例:
“`jsx
import React, { useState } from ‘react’;
import { usePrevious } from ‘./usePrevious’; // 上記のカスタムフックをインポート
function CounterWithPrevious() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
const direction =
prevCount === undefined
? ‘初期状態’
: count > prevCount
? ‘増加’
: count < prevCount
? ‘減少’
: ‘変化なし’;
return (
前回の値の保持
現在のカウント: {count}
前回のカウント: {prevCount === undefined ? ‘N/A’ : prevCount}
変化の方向: {direction}
);
}
``usePrevious
このコンポーネントでは、フックを使って常に一世代前のcountの値をprevCountとして保持しています。これにより、現在値と前回値を比較して、変化の方向を簡単に導き出すことができます。このロジックをuseStateだけで実現しようとすると、より複雑な状態管理が必要になりますが、useRef` を使えばエレガントに解決できます。
2.4. タイマーとインターバルの管理
setTimeout や setInterval を useEffect 内で使うのは一般的ですが、そのタイマーIDの管理には useRef が最適です。
シナリオ: ストップウォッチの実装
setInterval を開始・停止・リセットできるストップウォッチを考えてみましょう。
useRef を使わない場合の問題点:
もしタイマーIDを useState で管理しようとすると、IDがセットされるたびに再レンダリングが走り非効率です。また、useEffect のクリーンアップ関数内でタイマーをクリアしようとすると、クロージャの問題で古いIDを参照してしまう可能性があります。
useRef を使った堅牢な実装:
“`jsx
import React, { useState, useRef, useCallback } from ‘react’;
function Stopwatch() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null); // タイマーIDを保持するためのref
const start = useCallback(() => {
if (isRunning) return;
setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime(prevTime => prevTime + 10); // 10ミリ秒ごとに更新
}, 10);
}, [isRunning]);
const stop = useCallback(() => {
if (!isRunning) return;
setIsRunning(false);
clearInterval(intervalRef.current); // refに保存されたIDでクリア
intervalRef.current = null;
}, [isRunning]);
const reset = useCallback(() => {
setIsRunning(false);
clearInterval(intervalRef.current);
intervalRef.current = null;
setTime(0);
}, []);
// コンポーネントがアンマウントされるときにタイマーを確実にクリア
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
タイマー管理
{(“0” + Math.floor((time / 60000) % 60)).slice(-2)}:
{(“0” + Math.floor((time / 1000) % 60)).slice(-2)}:
{(“0” + ((time / 10) % 100)).slice(-2)}
);
}
``intervalRef` です。
この実装のポイントは
start関数でsetIntervalを実行し、返されたIDをintervalRef.currentに保存します。stopやreset関数では、intervalRef.currentにアクセスしてclearIntervalを呼び出します。intervalRefは再レンダリングされても同一のオブジェクトであるため、どの関数からでも常に最新のタイマーIDに確実にアクセスできます。useEffectのクリーンアップ関数でも、コンポーネントが消える際にタイマーが動いていれば確実にクリアされ、メモリリークを防ぎます。
このように、useRef はコンポーネントのライフサイクルにまたがる「副作用のリソース(この場合はタイマーID)」を管理するための、安全で信頼性の高い保管場所を提供します。
第3章: useRefと他のフックとの連携
useRef の真価は、他のフックと組み合わせることでさらに発揮されます。ここでは、useEffect、useCallback、そして forwardRef や useImperativeHandle との連携パターンを見ていき、より高度で洗練されたコンポーネント設計を探求します。
3.1. useRef と useEffect
この2つのフックは非常に相性が良く、多くのパターンで共に使われます。
DOM操作とクリーンアップ
第1章で見たDOMフォーカスの例や、第2章のタイマー管理の例は、まさに useRef と useEffect の典型的な連携パターンです。useEffect はコンポーネントのライフサイクルイベント(マウント、更新、アンマウント)を捉えるためのフックであり、useRef で保持したDOMノードやリソースに対して、適切なタイミングで操作やクリーンアップを行うことができます。
“`jsx
// WebSocket接続の管理の例
function ChatComponent({ url }) {
const socketRef = useRef(null);
useEffect(() => {
// マウント時にWebSocket接続を確立
socketRef.current = new WebSocket(url);
console.log(‘WebSocket接続を開始’);
socketRef.current.onopen = () => console.log('接続成功');
socketRef.current.onmessage = (event) => {
console.log('受信:', event.data);
// ここで受信データをstateにセットするなどの処理
};
// アンマウント時に接続を閉じるクリーンアップ処理
return () => {
if (socketRef.current) {
socketRef.current.close();
console.log('WebSocket接続を終了');
}
};
}, [url]); // urlが変わったら再接続する
// …
}
“`
useEffect の依存配列の罠を回避する
useEffect の依存配列に関数を含めると、コンポーネントが再レンダリングされるたびに(関数が再生成されるため)useEffect が実行されてしまう問題があります。通常は useCallback で関数をメモ化しますが、useRef を使ってこの問題を回避するテクニックも存在します。
“`jsx
function MyComponent({ onSomeEvent }) {
// onSomeEventは親コンポーネントから渡される関数で、再レンダリングのたびに再生成される可能性がある
const onSomeEventRef = useRef(onSomeEvent);
// 常に最新のonSomeEventをrefに格納する
useEffect(() => {
onSomeEventRef.current = onSomeEvent;
}, [onSomeEvent]);
useEffect(() => {
const handler = (event) => {
// ref経由で常に最新の関数を呼び出す
onSomeEventRef.current(event);
};
// このuseEffectはマウント時に一度しか実行されない
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []); // 依存配列が空!
// …
}
``window
このパターンでは、へのイベントリスナーの登録・解除をコンポーネントのマウント・アンマウント時に一度だけ行います。リスナー内のhandlerは、useRefを経由して常に最新のonSomeEventプロップスを参照できるため、親から渡される関数が変更されても、リスナーを再登録する必要がありません。これにより、不要なaddEventListener/removeEventListener` の呼び出しを防ぎ、パフォーマンスを向上させることができます。
3.2. useRef と useCallback
Stale Closure(古いクロージャ)問題を解決する
useCallback は関数をメモ化するのに便利ですが、依存配列にすべての依存関係を含めないと、関数が古い state や props を参照し続ける「Stale Closure」という問題が発生します。しかし、依存関係をすべて含めると、結局は頻繁に関数が再生成され、useCallback の意味が薄れてしまうことがあります。
このジレンマを、useRef を使って解決できます。
シナリオ: イベントハンドラ内で常に最新の state を参照したい
“`jsx
import React, { useState, useCallback, useRef, useEffect } from ‘react’;
function StaleClosureExample() {
const [count, setCount] = useState(0);
// countの最新値を常に保持するref
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
// このuseCallbackの依存配列は空。つまり関数は初回しか生成されない。
const logCount = useCallback(() => {
// stateの’count’を直接参照すると、初回レンダリング時の0のまま(Stale Closure)
// console.log(古い値: ${count});
// refを経由することで、常に最新の値にアクセスできる
setTimeout(() => {
alert(`現在のカウントは ${countRef.current} です`);
}, 2000);
}, []); // 依存配列は空
return (
Stale Closureの回避
カウント: {count}
);
}
``logCount
この例では、関数はuseCallbackによって初回レンダリング時に一度だけ生成されます。そのため、クロージャ内のcountという変数は0のままです。しかし、countRefはuseEffectによって常に最新のcountの値に更新され続けています。logCountは、このcountRef.current` を参照することで、自身の生成時ではなく 実行時 の最新のカウント値を取得できます。
これにより、関数の再生成を抑えつつ、常に最新の state にアクセスするという、両方の利点を享受できます。これは、複雑なイベントハンドラや、useEffect 内で非同期処理を行う際に非常に有効なテクニックです。
3.3. useRef と forwardRef
通常、ref は子コンポーネントに直接渡すことはできません。なぜなら ref は key と同様にReactによって特別に扱われるプロパティだからです。しかし、親コンポーネントから子コンポーネント内のDOM要素を操作したい(例えば、カスタム入力フィールドにフォーカスを当てたい)という要求は頻繁に発生します。
この橋渡し役を担うのが forwardRef です。
シナリオ: 親から子コンポーネントの input 要素にフォーカスを当てる
“`jsx
import React, { useRef, forwardRef } from ‘react’;
// forwardRefでコンポーネントをラップする
const CustomInput = forwardRef((props, ref) => {
return (
{/ 親から渡されたrefを、内部のinput要素のref属性に設定する /}
);
});
function ParentComponent() {
const inputRef = useRef(null);
const handleFocus = () => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.value = “フォーカスしました!”;
}
};
return (
forwardRefの使用例
{/ 作成したrefを、子コンポーネントに渡す /}
);
}
``forwardRefは、コンポーネントをラップし、propsに加えて第2引数としてrefを受け取れるようにします。これにより、親でuseRefを使って作成したrefオブジェクトを、子コンポーネントにプロパティのように渡すことができます。子コンポーネントは受け取ったrefを、自分が管理したい内部のDOM要素(この例では`)にアタッチします。
結果として、親コンポーネントの inputRef.current は、CustomInput コンポーネント内部の <input> DOM要素を直接指すことになり、親から focus() などのDOM APIを呼び出すことが可能になります。これは、再利用可能なコンポーネントライブラリを作成する上で必須のテクニックです。
3.4. useRef と useImperativeHandle
forwardRef は便利ですが、子コンポーネントのDOMノード全体を親に公開してしまいます。これにより、親コンポーネントが子の内部実装に過度に依存してしまう危険性があります。例えば、親が inputRef.current.style.backgroundColor = 'red' のように、子のスタイルを勝手に変更できてしまいます。
useImperativeHandle を使うと、親に公開する機能をより厳密に制御できます。これは、子コンポーネントが親に対して「命令的なAPI」を定義するためのフックです。
シナリオ: focus と clear メソッドだけを公開する入力コンポーネント
“`jsx
import React, { useRef, useImperativeHandle, forwardRef, useState } from ‘react’;
const ControlledCustomInput = forwardRef((props, ref) => {
const internalInputRef = useRef(null);
const [value, setValue] = useState(”);
// 親に公開するハンドル(API)を定義する
useImperativeHandle(ref, () => ({
// ‘focus’という名前で、この関数を公開
focus: () => {
internalInputRef.current.focus();
},
// ‘clear’という名前で、この関数を公開
clear: () => {
setValue(”);
internalInputRef.current.value = ”;
},
// ‘value’プロパティを読み取り専用で公開
get value() {
return internalInputRef.current.value;
}
// これ以外のDOMプロパティやメソッドは親からアクセスできない
}));
return (
setValue(e.target.value)}
{…props}
/>
);
});
function ImperativeParent() {
const inputApiRef = useRef(null);
const handleFocus = () => {
// 公開された’focus’メソッドを呼び出す
inputApiRef.current.focus();
};
const handleClear = () => {
// 公開された’clear’メソッドを呼び出す
inputApi-Ref.current.clear();
};
const showValue = () => {
// 公開された’value’ゲッターを呼び出す
alert(inputApi-Ref.current.value);
}
return (
useImperativeHandleの使用例
);
}
``useImperativeHandleはforwardRefと一緒に使います。第1引数に親から渡されたref`、第2引数に公開したいAPIを定義したオブジェクトを返す関数、第3引数に依存配列(オプション)を取ります。
この例では、親コンポーネントの inputApiRef.current はもはやDOMノードそのものではなく、useImperativeHandle の第2引数で定義した { focus: fn, clear: fn, value: getter } というオブジェクトを指します。これにより、親は inputApiRef.current.focus() や inputApiRef.current.clear() を呼び出すことはできますが、inputApiRef.current.style のような内部実装にアクセスすることはできません。
このように、useImperativeHandle はコンポーネントのカプセル化を強化し、よりクリーンで保守性の高いコンポーネント間のインターフェースを設計するのに役立ちます。
第4章: useRefの注意点とアンチパターン
useRef は非常に強力なツールですが、その力を誤用すると、かえってコードを複雑にし、バグの原因となることがあります。ここでは、useRef を使う上で注意すべき点と、避けるべきアンチパターンについて解説します。
4.1. レンダリングに使ってはならない
これは useRef を扱う上での絶対的な原則です。.current プロパティの値を変更しても、UIは更新されません。
UIに表示されるべきデータ、つまりその値の変更が画面の再描画を必要とするデータは、必ず useState や useReducer で管理してください。
アンチパターンの例:
“`jsx
function AntiPatternRefRender() {
const countRef = useRef(0);
const increment = () => {
countRef.current++;
// UIを更新しようとして、手動で再レンダリングをトリガーしようとするのはNG
// forceUpdate(); // このようなハックは避けるべき
console.log(countRef.current);
};
return (
カウント: {countRef.current}
);
}
“`
ref の値が変更されたことをUIに反映させたいのであれば、それは ref の役割ではありません。素直に state を使いましょう。もし、値の更新頻度が高すぎて state を使うとパフォーマンスが問題になるのであれば、それは「UIの更新自体を間引く(Debounce/Throttle)」という別の戦略を検討すべきであり、ref の値を無理やりレンダリングしようとするのは設計ミスです。
4.2. useEffectの依存配列とref
useEffect の依存配列に ref オブジェクトそのもの(例: myRef)を含めても意味がありません。なぜなら、ref オブジェクトはコンポーネントのライフサイクルを通じて常に同じインスタンスだからです。
jsx
useEffect(() => {
// このeffectは初回レンダリング時に一度しか実行されない
console.log('effect');
}, [myRef]); // myRefオブジェクトは不変なので、この依存配列は効果がない
では、ref.current の値の変更を検知して useEffect を実行したい場合はどうすればよいでしょうか?
残念ながら、それを直接行うクリーンな方法はありません。ref.current の変更はレンダリングをトリガーしないため、Reactのライフサイクルに組み込まれていないからです。
もし .current の変更に反応して副作用を実行したいのであれば、それは useRef の使い方として不自然である可能性が高いです。おそらく、その値は useState で管理し、useEffect の依存配列にその state を含めるのが適切な設計でしょう。
4.3. サーバーサイドレンダリング (SSR) での注意点
Next.js や Gatsby のようなフレームワークでサーバーサイドレンダリング(SSR)を行う場合、useRef をDOM要素の参照に使う際には注意が必要です。
サーバー上では、JavaScriptは実行されますが、DOM(ブラウザのドキュメント構造)は存在しません。そのため、サーバーでのレンダリング段階では、DOM要素にアタッチされるはずの ref.current は常に null または undefined になります。
問題が発生するコード:
“`jsx
function SsrProblemComponent() {
const divRef = useRef(null);
// このコードはサーバー上で実行されるとエラーになる
// divRef.current が null なので .getBoundingClientRect は呼び出せない
const width = divRef.current.getBoundingClientRect().width;
return
;
}
“`
解決策:
DOMに依存する処理は、必ずコンポーネントがクライアントサイドでマウントされた後に実行する必要があります。そのための最適な場所が useEffect です。
“`jsx
function SsrSafeComponent() {
const divRef = useRef(null);
const [width, setWidth] = useState(0);
useEffect(() => {
// useEffectはクライアントサイドでのみ実行される
if (divRef.current) {
setWidth(divRef.current.getBoundingClientRect().width);
}
}, []); // マウント時に一度だけ実行
return
;
}
``useEffectは、サーバーレンダリングのプロセスでは実行されず、ブラウザにコンポーネントがマウントされた後に初めて実行されます。このタイミングであればdivRef.current` は正しくDOM要素を指しているため、安全にDOM APIを呼び出すことができます。
4.4. “濫用”の危険性
useRef は、Reactの宣言的なパラダイムからの「脱出口」を提供します。DOMを直接操作したり、再レンダリングサイクル外で値を変更したりすることは、命令的なプログラミングスタイルです。これは特定の状況下で非常に便利ですが、濫用するとReactの利点を損なう可能性があります。
- 状態管理の複雑化:
useStateとuseRefで管理される値がアプリケーション内に散在すると、データの流れが追跡しにくくなります。「いつ、何が、どこで更新され、その結果として何が起こるのか」という見通しが悪くなり、デバッグが困難になることがあります。 - 宣言的な思想からの逸脱: Reactの強みは、UIを現在の状態の関数として表現できることです(
UI = f(state))。useRefを使った命令的な操作が増えすぎると、このシンプルなモデルが崩れ、コンポーネントの挙動が予測しにくくなります。
思考プロセスとして推奨されるのは、「まず useState で考える」ことです。 そして、パフォーマンス計測などを行った結果、明確に不要な再レンダリングがボトルネックになっていると判明した場合にのみ、その特定の箇所を useRef で最適化することを検討しましょう。「早すぎる最適化は諸悪の根源」という格言は、ここでも当てはまります。
結論:useRefを戦略的に使いこなす
useRef は、Reactフックの中でも特にユニークで強力なツールです。その本質を理解することで、私たちはReactアプリケーションのパフォーマンスを新たなレベルに引き上げることができます。
この記事を通じて、私たちは以下の重要なポイントを学びました。
useRefの核心:useRefは、再レンダリングをトリガーすることなく、コンポーネントのライフサイクルを通じて永続するミュータブルな値を保持するための「箱」である。- 2つの主要な用途: 最も一般的な「DOM要素への参照」に加えて、「再レンダリングを伴わない値の保持」がパフォーマンス最適化の鍵となる。
- 具体的な改善テクニック: 高頻度イベントの処理、高コストな計算のキャッシュ、前回の値の保持、タイマーIDの管理など、
useRefを活用して不要な再レンダリングを削減する具体的な方法を学びました。 - 他のフックとの連携:
useEffect、useCallback、forwardRef、useImperativeHandleと組み合わせることで、よりクリーンで、堅牢で、保守性の高い高度なコンポーネントを設計できることを確認しました。 - 注意点とバランス:
useRefは強力な反面、誤用するとコードを複雑にする危険性もはらんでいます。UIに表示すべき値にはuseStateを使い、useRefは命令的な操作やパフォーマンス上の明確な理由がある場合に限定して使用する、というバランス感覚が重要です。
useRef は、Reactの宣言的な世界のすぐそばにある、命令的な操作のための小さな、しかし強力な避難ハッチです。それは「銀の弾丸」ではありませんが、パフォーマンスのボトルネックを特定し、それをピンポイントで解決するための鋭いメスとなり得ます。
今日学んだ知識を武器に、あなたのReactアプリケーションを分析し、どこで useRef が効果を発揮できるかを探してみてください。不要な再レンダリングを一つ一つ潰していくことで、ユーザーにとってより快適で、開発者にとってより誇れる、高速で応答性の高いアプリケーションを構築できるはずです。useRef をマスターし、React開発の新たな高みを目指しましょう。