「useRef 使い方を実務レベルで体系化したい」「useRef DOM 操作はわかるけど、前回値保持・タイマー管理・mutable valueとしての使い分けが曖昧」「React 19でforwardRefが非推奨になったらしいけど、既存コードはどう書き換えればいい?」――この記事はそんな現役ReactエンジニアのためのuseRef完全実践ガイドです。useRefはReactの組み込みフックの中でも、「DOM参照・mutable value(レンダリングなし状態)・stable identity」という3つの異なる用途を1つのAPIに押し込んでいるため、初学者・中級者問わず混乱しやすいフックです。本記事ではuseRefの本質を「レンダリングをトリガしない箱」として再定義し、useStateやuseEffectとの明確な境界線を示します。input自動フォーカス・textareaリサイズ・スクロール位置記憶・video/canvas操作・前回値保持(usePrevious)・タイマー管理・AbortController・forwardRef・useImperativeHandle・callback ref・focus trap・drag-and-drop・WebSocket管理・IntersectionObserver連携・React 19 props.ref対応まで、コピペで動くTypeScriptコード30本超・表3つ・FAQ7問で徹底解説します。読み終えるころには、useRefを使うべき場面・使ってはいけない場面・最新APIへの移行手順を、自分の言葉で説明できるようになっているはずです。
- useRefとは何か〜「レンダリングをトリガしない箱」という本質
- インストールとインポート〜useRefは標準フック
- 用途①DOM参照〜useRefの最頻出ユースケース
- 用途②mutable value〜「再描画したくない状態」を抱える
- 用途③stable identity〜タイマー・接続・購読の管理
- forwardRefとuseImperativeHandle〜親から子DOMを操作する
- callback refとobject refの違いを理解する
- refをforwardしない・しないと困る場面の判断
- useRefとuseEffect・useStateとの組み合わせパターン
- テスト戦略〜useRefを使ったコンポーネントの検証
- アンチパターンとリファクタリング
- useRefを使うべきか・別フックを選ぶべきかの判断表
- FAQ〜実務で頻出する疑問
- まとめ〜useRefの3用途を体に染み込ませる
useRefとは何か〜「レンダリングをトリガしない箱」という本質
useRefはReactの組み込みフックで、「コンポーネントの再レンダリングを跨いで値を保持し、かつその値の変更が再レンダリングを発生させない」という極めて特殊な性質を持ちます。useStateが「値の更新で再レンダリングを起こす」のに対し、useRefは「値を更新しても再レンダリングしない」――この一点だけ覚えておけば、用途の8割はカバーできます。
useRefが解決する3つの用途
useRefの公式ドキュメントは、用途を3つに整理しています。本記事の構成もこの3分類に対応しているので、まず頭に入れておいてください。
// useRefの3用途を1コードで俯瞰
import { useRef, useEffect } from "react";
export function ThreeUseCases() {
// ① DOM参照: input要素にアクセスする
const inputRef = useRef<HTMLInputElement>(null);
// ② mutable value: 再レンダリングを起こさず値を保持
const countRef = useRef<number>(0);
// ③ stable identity: タイマーIDやAbortControllerなど
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
inputRef.current?.focus(); // ①
countRef.current += 1; // ② (再描画なし)
timerRef.current = setTimeout(() => {}, 1000); // ③
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return <input ref={inputRef} />;
}
useStateとuseRefの境界線
「その値の変化を画面に反映する必要があるか?」を判断軸にしてください。反映するならuseState、反映しないならuseRef――この原則だけで、99%の場面で正しい選択ができます。
| 観点 | useState | useRef |
|---|---|---|
| 値の更新 | setStateで非同期 | ref.current = で同期即時 |
| 再レンダリング | 発生する | 発生しない |
| 初期化タイミング | マウント時1回 | マウント時1回(同じ) |
| useEffectの依存配列 | 入れるべき | 入れても意味なし |
| 主な用途 | 表示する状態 | DOM参照・タイマーID・前回値 |
useMemoとも違う〜キャッシュではなく「箱」
useMemoと混同されがちですが、useMemoは「依存配列が変わったら再計算するキャッシュ」、useRefは「依存配列なしで永続的に存続する箱」です。React StrictModeでの二重実行も、useRefの.currentはリセットされない点に注意してください。
インストールとインポート〜useRefは標準フック
追加パッケージ不要・Reactから直接import
useRefはReact本体に含まれる組み込みフックです。追加パッケージは一切不要で、reactパッケージから直接importします。TypeScript用の型は@types/reactに同梱されているので、こちらも別途インストールする必要はありません。
// 基本のimport
import { useRef } from "react";
// 型もまとめてimportしたい場合(高度なジェネリック型のときに便利)
import { useRef, type MutableRefObject, type RefObject } from "react";
React 19での型の変化
React 19ではMutableRefObjectとRefObjectの区別がやや変わり、RefObject<T>は読み書き両方可能なように統一されました(以前はreadonlyだった)。古いコードを19に移行する場合、as MutableRefObjectのキャストを外せるケースがあります。
// React 18時代の書き方(currentがreadonlyだった)
const ref = useRef<HTMLDivElement>(null); // RefObject<HTMLDivElement>
// ref.current = newNode; ← 18ではTSエラー
// React 19以降は普通に代入可能
ref.current = newNode; // OK
用途①DOM参照〜useRefの最頻出ユースケース
input要素への自動フォーカス
もっとも典型的なuseRefの用法が、マウント時のinput自動フォーカスです。autoFocus属性はアクセシビリティ上の懸念があるため、useRefとuseEffectの組み合わせで明示的に呼ぶのが推奨パターンです。
// input自動フォーカスの定番パターン
import { useEffect, useRef } from "react";
export function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// ?. で null チェック(マウント前は null)
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="自動でフォーカスされます" />;
}
textareaの自動リサイズ
入力量に応じてtextareaの高さを自動調整する処理は、useRefなしには書けません。scrollHeightを測ってstyleを書き換えます。state経由ではDOMの実測値が取れないので、ここは素直にrefを使うのが正解です。
// textareaの内容に応じて高さを自動調整
import { ChangeEvent, useRef } from "react";
export function AutoResizeTextarea() {
const ref = useRef<HTMLTextAreaElement>(null);
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const el = ref.current;
if (!el) return;
el.style.height = "auto"; // 一旦リセット
el.style.height = `${el.scrollHeight}px`; // 実測値に合わせる
};
return (
<textarea
ref={ref}
onChange={onChange}
rows={1}
style={{ resize: "none", width: "100%" }}
/>
);
}
スクロール位置の記憶と復元
SPAでページ遷移時にスクロール位置を保存し、戻ったときに復元する処理もuseRefの典型例です。スクロール位置は表示に直接関与しないためuseStateにする必要はなく、refで十分です。
// 一覧→詳細→戻るでスクロール位置を復元
import { useEffect, useRef } from "react";
export function ScrollMemoryList({ items }: { items: string[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const scrollTopRef = useRef<number>(0);
// アンマウント時にスクロール位置を保存
useEffect(() => {
return () => {
scrollTopRef.current = containerRef.current?.scrollTop ?? 0;
};
}, []);
// マウント時に復元
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = scrollTopRef.current;
}
}, []);
return (
<div ref={containerRef} style={{ height: 400, overflowY: "auto" }}>
{items.map((it) => (
<div key={it} style={{ padding: 12 }}>{it}</div>
))}
</div>
);
}
video要素の再生制御
HTMLVideoElementのplay/pause/seekなどは命令的APIなので、refから直接呼び出すのが正攻法です。state経由でやろうとすると、ライフサイクルのズレで「再生しようとしたら要素がまだない」といったバグになりがちです。
// video要素を命令的に操作
import { useRef, useState } from "react";
export function VideoPlayer({ src }: { src: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const toggle = async () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
await v.play();
setPlaying(true);
} else {
v.pause();
setPlaying(false);
}
};
const seekTo = (sec: number) => {
if (videoRef.current) videoRef.current.currentTime = sec;
};
return (
<div>
<video ref={videoRef} src={src} width={480} />
<button onClick={toggle}>{playing ? "停止" : "再生"}</button>
<button onClick={() => seekTo(10)}>10秒へ</button>
</div>
);
}
canvas要素への描画
Canvas APIはDOMノードに対する命令的なAPIの塊なので、ほぼ必ずuseRef経由でアクセスします。React外のライブラリ(Chart.js、Three.js等)を埋め込むときも同じパターンが使えます。
// canvasに簡単な図形を描画
import { useEffect, useRef } from "react";
export function CanvasDemo() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#0ea5e9";
ctx.fillRect(20, 20, 200, 100);
ctx.fillStyle = "#fff";
ctx.font = "20px sans-serif";
ctx.fillText("useRef × canvas", 40, 80);
}, []);
return <canvas ref={canvasRef} width={400} height={200} />;
}
focus trapの実装(モーダル内のキーボードナビゲーション)
モーダル内でTabキーを押したときにフォーカスを内部に閉じ込める「focus trap」は、アクセシビリティ要件として頻出です。最初と最後のフォーカス可能要素をrefで掴み、Shift+Tab/Tabでループさせます。
// 最小実装のfocus trap
import { useEffect, useRef, ReactNode } from "react";
export function Modal({ children, onClose }: { children: ReactNode; onClose: () => void }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = containerRef.current;
if (!root) return;
const focusables = root.querySelectorAll<HTMLElement>(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
first?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};
root.addEventListener("keydown", onKey);
return () => root.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div ref={containerRef} role="dialog" aria-modal="true">
{children}
</div>
);
}
用途②mutable value〜「再描画したくない状態」を抱える
前回の値を保持するusePreviousパターン
「propsが前回と違うときだけ何かしたい」という要件は実務で頻出ですが、useStateで前回値を持つと無限ループのリスクがあります。useRefで保持するのが定石です。カスタムフックとして切り出すのが王道です。
// usePrevious: 前回のレンダリング時の値を返すフック
import { useEffect, useRef } from "react";
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value; // 描画後に更新
}, [value]);
return ref.current; // 描画中はまだ前回値
}
// 使い方
function Counter({ count }: { count: number }) {
const prev = usePrevious(count);
return <p>現在:{count} / 前回:{prev ?? "-"}</p>;
}
レンダリングと無関係なフラグを抱える
「初回マウントかどうか」のフラグや「ドラッグ中かどうか」の一時状態など、画面表示と直接関係ない値はuseRefで持つのがクリーンです。useStateにすると無駄な再レンダリングを誘発します。
// 初回マウントだけスキップするパターン
import { useEffect, useRef } from "react";
export function useEffectAfterMount(effect: () => void, deps: unknown[]) {
const isFirst = useRef(true);
useEffect(() => {
if (isFirst.current) {
isFirst.current = false;
return; // 初回はスキップ
}
return effect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
カウンタを再描画なしで増やす
分析用のクリック回数カウントなど、UIには見せず内部だけで集計したい数値はrefで持ちます。送信タイミングで値を読めば十分で、毎クリック再描画する必要はありません。
// クリック数を裏で集計し、5回ごとに送信
import { useRef } from "react";
export function HiddenCounter() {
const clickCountRef = useRef(0);
const onClick = () => {
clickCountRef.current += 1;
if (clickCountRef.current % 5 === 0) {
console.log("[analytics] clicks:", clickCountRef.current);
// sendToServer(clickCountRef.current);
}
};
return <button onClick={onClick}>クリックしてください</button>;
}
drag-and-drop位置の追跡
ドラッグ中の座標は1pxごとに変化するので、useStateで持つと再レンダリング地獄になります。refで保持して、必要なときだけstateに反映するのが鉄則です。
// ドラッグ中はrefで追跡、ドロップ時のみstate確定
import { useRef, useState } from "react";
type Point = { x: number; y: number };
export function Draggable() {
const startRef = useRef<Point | null>(null);
const [pos, setPos] = useState<Point>({ x: 0, y: 0 });
const onMouseDown = (e: React.MouseEvent) => {
startRef.current = { x: e.clientX - pos.x, y: e.clientY - pos.y };
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp, { once: true });
};
const onMove = (e: MouseEvent) => {
if (!startRef.current) return;
// ここではrefだけ更新したい場合はsetPosを呼ばないことも可能
setPos({ x: e.clientX - startRef.current.x, y: e.clientY - startRef.current.y });
};
const onUp = () => {
startRef.current = null;
window.removeEventListener("mousemove", onMove);
};
return (
<div
onMouseDown={onMouseDown}
style={{ position: "absolute", left: pos.x, top: pos.y, width: 80, height: 80, background: "#0ea5e9" }}
/>
);
}
用途③stable identity〜タイマー・接続・購読の管理
setTimeout/setIntervalのIDを保持する
タイマーIDはコンポーネントの再描画を跨いで一貫した値である必要があります。state化すると毎レンダリングで新しいIDが入って解除できなくなるため、useRefで持ちます。useEffectのクリーンアップとセットで覚えてください。
// インターバルのIDをrefで管理
import { useEffect, useRef, useState } from "react";
export function IntervalCounter() {
const [count, setCount] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
timerRef.current = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
return <p>{count}</p>;
}
デバウンス処理の実装
検索インプットなどで頻繁に発生する処理を間引くデバウンス処理も、refにタイマーIDを保持するのが定型パターンです。lodash.debounceに頼らずとも、これだけで実装できます。
// 検索入力をデバウンス
import { useEffect, useRef, useState } from "react";
export function DebouncedSearch() {
const [input, setInput] = useState("");
const [committed, setCommitted] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setCommitted(input);
}, 400);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [input]);
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<p>confirmed: {committed}</p>
</div>
);
}
AbortControllerで進行中fetchをキャンセル
useEffect内でfetchを呼ぶときの定石が、AbortControllerでクリーンアップ時にキャンセルするパターンです。コントローラインスタンスをrefで保持しておけば、外部から手動キャンセルもできます。
// useEffect + AbortControllerでレース状態を防ぐ
import { useEffect, useRef, useState } from "react";
export function UserProfile({ userId }: { userId: string }) {
const [data, setData] = useState<{ name: string } | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
controllerRef.current?.abort(); // 前回のリクエストを止める
const controller = new AbortController();
controllerRef.current = controller;
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((e) => {
if (e.name !== "AbortError") console.error(e);
});
return () => controller.abort();
}, [userId]);
return data ? <p>{data.name}</p> : <p>loading...</p>;
}
WebSocket接続のライフサイクル管理
WebSocketインスタンスは「接続中のオブジェクト」そのものをrefで保持し、unmount時に必ずclose()を呼ぶのが必須です。state化すると接続のたびに再描画が走り、ハンドラの再登録漏れも発生しやすくなります。
// WebSocket接続をrefで管理
import { useEffect, useRef, useState } from "react";
export function ChatRoom({ url }: { url: string }) {
const wsRef = useRef<WebSocket | null>(null);
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onmessage = (e) => setMessages((m) => [...m, e.data]);
return () => {
ws.close();
wsRef.current = null;
};
}, [url]);
const send = (text: string) => wsRef.current?.send(text);
return (
<div>
<button onClick={() => send("hello")}>送信</button>
<ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>
</div>
);
}
IntersectionObserverによる遅延読み込み
無限スクロールや遅延画像読み込みで使うIntersectionObserverも、observerインスタンスと監視対象DOMの両方をrefで管理するのが定石です。
// 要素がviewportに入ったら次ページを読み込む
import { useCallback, useEffect, useRef } from "react";
export function InfiniteScroll({ onReachEnd }: { onReachEnd: () => void }) {
const sentinelRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (!sentinelRef.current) return;
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting) onReachEnd();
});
observerRef.current.observe(sentinelRef.current);
return () => observerRef.current?.disconnect();
}, [onReachEnd]);
return <div ref={sentinelRef} style={{ height: 1 }} />;
}
ResizeObserverで要素サイズを追跡
要素のサイズ変化に応じて挙動を変えたい場合はResizeObserverを使います。これもインスタンスをrefで保持して、unmount時にdisconnect()を忘れずに呼びます。
// 要素サイズをstateに反映するカスタムフック
import { useEffect, useRef, useState } from "react";
export function useElementSize<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!ref.current) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setSize({ width, height });
});
ro.observe(ref.current);
return () => ro.disconnect();
}, []);
return [ref, size] as const;
}
forwardRefとuseImperativeHandle〜親から子DOMを操作する
forwardRefの基本(React 18まで)
子コンポーネントのDOMを親から触りたい場合、React 18まではforwardRefでラップする必要がありました。型安全に書くにはforwardRef<RefType, PropsType>の形でジェネリックを指定します。
// forwardRefで子のinputを親から参照(React 18パターン)
import { forwardRef } from "react";
type Props = { label: string };
export const LabeledInput = forwardRef<HTMLInputElement, Props>(
function LabeledInput({ label }, ref) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
);
// 親側
function Parent() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<LabeledInput ref={inputRef} label="氏名" />
<button onClick={() => inputRef.current?.focus()}>focus</button>
</>
);
}
React 19以降〜props.refで直接受け取れる
React 19からrefは普通のpropsとして受け取れるようになり、forwardRefは不要になりました(将来的に非推奨化)。新規コードはこちらの記法を採用してください。
// React 19以降のシンプル記法
import { type Ref } from "react";
type Props = {
label: string;
ref?: Ref<HTMLInputElement>;
};
export function LabeledInput({ label, ref }: Props) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
// 親側は変わらず使える
function Parent() {
const inputRef = useRef<HTMLInputElement>(null);
return <LabeledInput ref={inputRef} label="氏名" />;
}
forwardRefからprops.refへの移行手順
既存のforwardRefコードをReact 19式に書き換える手順は次の通り。codemodが公式から提供されており、自動変換も可能です。
npx codemod@latest react/19/replace-use-form-state系のforwardRef-to-ref-propを実行- 型エラーが残る箇所は
ref?: Ref<T>をPropsに追加 - 関数ボディから第2引数の
refを削除し、propsからの分割代入に変更 - 外側の
forwardRef(...)呼び出しを除去
useImperativeHandleで命令的APIを公開する
子コンポーネントが内部に複雑なロジックを抱えていて、それを親から「focus()だけ」「reset()だけ」のように限定公開したい場合に使うのがuseImperativeHandleです。生のDOMを露出させずに済むので、カプセル化が保てます。
// useImperativeHandleで命令的APIを定義
import { useImperativeHandle, useRef, type Ref } from "react";
export type FancyInputHandle = {
focus: () => void;
reset: () => void;
};
type Props = { ref?: Ref<FancyInputHandle> };
export function FancyInput({ ref }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
reset: () => {
if (inputRef.current) inputRef.current.value = "";
},
}), []);
return <input ref={inputRef} />;
}
// 親側
function Parent() {
const handle = useRef<FancyInputHandle>(null);
return (
<>
<FancyInput ref={handle} />
<button onClick={() => handle.current?.focus()}>focus</button>
<button onClick={() => handle.current?.reset()}>reset</button>
</>
);
}
useImperativeHandleを濫用しない
useImperativeHandleは「本当に命令的にしか書けない場面(focus・scroll・play等)」だけに留めてください。状態の同期や値の取得に使い始めると、宣言的UIの思想が崩壊し、デバッグ困難になります。
callback refとobject refの違いを理解する
callback refとは何か
refには2種類あります。useRefで作るobject refと、関数を直接渡すcallback refです。callback refは要素がマウント/アンマウントされる瞬間に呼ばれるため、DOMが付いた直後に副作用を起こせます。
// callback refの基本
import { useCallback, useState } from "react";
export function MeasureOnMount() {
const [height, setHeight] = useState<number | null>(null);
// マウント時:node !== null / アンマウント時:node === null
const measureRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<div ref={measureRef}>
{height !== null && <p>高さ: {height}px</p>}
</div>
);
}
条件付きで要素が現れる場合はcallback refが便利
「ある条件のときだけ表示される要素のサイズを測りたい」というケースでは、useRefだとマウントタイミングを掴みにくいですが、callback refなら一発で取れます。React 19以降はcallback refの返り値でクリーンアップ関数も登録できるようになりました。
// React 19: callback refのクリーンアップを返す
import { useCallback } from "react";
export function AutoListener() {
const ref = useCallback((node: HTMLDivElement | null) => {
if (!node) return;
const onClick = () => console.log("clicked");
node.addEventListener("click", onClick);
return () => node.removeEventListener("click", onClick); // 19以降OK
}, []);
return <div ref={ref}>click me</div>;
}
object refとcallback refの使い分け表
| 観点 | object ref (useRef) | callback ref |
|---|---|---|
| 取得タイミング | useEffect内で参照 | マウント直後に即実行 |
| 動的な要素 | 条件付き表示で扱いにくい | 素直に扱える |
| クリーンアップ | useEffectのreturnで | React 19以降は関数returnで |
| 記述量 | 少ない | やや多い |
| 主な用途 | 標準ケース | 要素サイズ測定・条件付きDOM |
refをforwardしない・しないと困る場面の判断
refを子に渡したいだけならpropsで十分
「子コンポーネント内の要素にrefを渡したい」だけなら、React 19以降はpropsとしてrefを受け取れるので、forwardRefは不要です。React 18でもユーティリティライブラリ(react-merge-refs等)で複数refをマージできます。
// 複数のrefを1つの要素にマージする
import { type Ref, useCallback } from "react";
function mergeRefs<T>(refs: Array<Ref<T> | undefined>): Ref<T> {
return (node: T | null) => {
refs.forEach((ref) => {
if (typeof ref === "function") ref(node);
else if (ref != null) (ref as React.MutableRefObject<T | null>).current = node;
});
};
}
// 使い方: 外部から渡されたrefと内部refを両立
export function Input({ ref: outerRef }: { ref?: Ref<HTMLInputElement> }) {
const innerRef = useRef<HTMLInputElement>(null);
const merged = useCallback(mergeRefs([innerRef, outerRef]), [outerRef]);
return <input ref={merged} />;
}
useRefとuseEffect・useStateとの組み合わせパターン
useEffectの依存配列にrefを入れない
useRefで作ったrefオブジェクトは再レンダリングを跨いで同一参照です。useEffectの依存配列に入れても発火しないので、入れる意味がありません。lintで警告されたら// eslint-disable-next-lineではなく、依存から外すのが正解です。
// NG: refを依存配列に入れる(無意味)
useEffect(() => {
console.log(myRef.current);
}, [myRef]); // ❌ myRefは不変なので発火しない
// OK: refの「中身」が変わったときに動かしたいならstateにする
const [value, setValue] = useState(0);
useEffect(() => {
console.log(value);
}, [value]); // ✅
レンダリング中にref.currentを書き換えない
レンダリング関数の中(JSXを返す直前まで)でref.current = ...と書くと、StrictModeでの二重実行や同時並行レンダリング(React 18 Concurrent)で不整合が起きます。書き換えは必ずuseEffect内かイベントハンドラ内に限定してください。
// NG: レンダリング中に書き換え
function Bad() {
const ref = useRef(0);
ref.current += 1; // ❌ 描画のたびに増える(StrictModeで倍々に)
return <p>{ref.current}</p>;
}
// OK: useEffect内で書き換え
function Good() {
const ref = useRef(0);
useEffect(() => {
ref.current += 1; // ✅ レンダリングの「後」で1回だけ
});
return <p>{ref.current}</p>;
}
初期化が重い場合はuseRefよりlazy initを使う
useRefの初期値にnew ExpensiveClass()のような重い処理を書くと、再レンダリングのたびに作られて捨てられる無駄が発生します。初回だけ生成したい場合は次のイディオムを使ってください。
// 重いインスタンスを初回マウント時だけ生成
import { useRef } from "react";
function useLazyRef<T>(init: () => T) {
const ref = useRef<T | null>(null);
if (ref.current === null) {
ref.current = init();
}
return ref as { current: T };
}
// 使い方
function Map() {
const mapRef = useLazyRef(() => new MapEngine({ heavyConfig: true }));
// mapRef.currentは常に同じインスタンス
return <div>...</div>;
}
テスト戦略〜useRefを使ったコンポーネントの検証
React Testing Libraryでの基本
useRefを使ったコンポーネントをテストする場合、refそのものをテストするのではなく、refを使った結果として現れる挙動(focus・scroll・テキスト変更等)を検証します。
// AutoFocusInputのテスト例
import { render, screen } from "@testing-library/react";
import { AutoFocusInput } from "./AutoFocusInput";
test("マウント時にinputがフォーカスされる", () => {
render(<AutoFocusInput />);
const input = screen.getByPlaceholderText("自動でフォーカスされます");
expect(input).toHaveFocus();
});
useImperativeHandleを公開しているコンポーネントの検証
命令的ハンドルはテストで直接呼び出せます。ハンドルを掴むためのテスト用propsを足すよりも、親コンポーネント経由でユーザー操作を再現するE2E寄りのテストが推奨です。
// FancyInputのreset()をテスト
import { render, screen, fireEvent } from "@testing-library/react";
import { useRef } from "react";
import { FancyInput, type FancyInputHandle } from "./FancyInput";
function Wrapper() {
const handle = useRef<FancyInputHandle>(null);
return (
<>
<FancyInput ref={handle} />
<button onClick={() => handle.current?.reset()}>reset</button>
</>
);
}
test("resetボタンで値が空になる", () => {
render(<Wrapper />);
const input = screen.getByRole("textbox") as HTMLInputElement;
fireEvent.change(input, { target: { value: "hello" } });
fireEvent.click(screen.getByText("reset"));
expect(input.value).toBe("");
});
アンチパターンとリファクタリング
Before: refで持つべき値をstateにしている
「画面に表示しないけど、状態を覚えておきたい」値をすべてuseStateで持つと、無駄な再レンダリングの嵐になります。
// Before: 毎クリック再描画(無駄)
import { useState } from "react";
export function BadAnalytics() {
const [clickCount, setClickCount] = useState(0);
return (
<button
onClick={() => {
setClickCount((c) => c + 1); // ❌ 表示しないのに再描画
if ((clickCount + 1) % 5 === 0) console.log("5回毎");
}}
>
クリック
</button>
);
}
After: refで保持して再描画ゼロ
// After: 再描画なし
import { useRef } from "react";
export function GoodAnalytics() {
const clickCountRef = useRef(0);
return (
<button
onClick={() => {
clickCountRef.current += 1; // ✅ 再描画ゼロ
if (clickCountRef.current % 5 === 0) console.log("5回毎");
}}
>
クリック
</button>
);
}
Before: stateで持つべき値をrefにしている
逆に「画面に出すべき値」をrefにすると、変更しても画面が更新されません。これも頻出のアンチパターンです。
// Before: 表示値をrefにして更新されないバグ
import { useRef } from "react";
export function BadCounter() {
const countRef = useRef(0);
return (
<button onClick={() => { countRef.current += 1; }}>
{countRef.current /* ❌ 更新されない */}
</button>
);
}
After: 表示値はstate、内部メモはrefと使い分け
// After: 表示はstate、内部はref
import { useRef, useState } from "react";
export function GoodCounter() {
const [count, setCount] = useState(0);
const startRef = useRef(Date.now());
return (
<button onClick={() => {
setCount((c) => c + 1);
console.log("経過:", Date.now() - startRef.current);
}}>
{count}
</button>
);
}
ESLint react-hooks/exhaustive-depsとの付き合い方
react-hooks/exhaustive-depsのルールは、refを依存配列に入れることを要求しません(refは不変参照だと知っている)。一方、refの.currentを使ってクロージャの古い値を参照しているコードは見抜けないので、レビューで補ってください。
// 古い値を読まないために、最新値をrefに同期するパターン
import { useEffect, useRef } from "react";
export function useEventHandler<T extends (...args: any[]) => any>(handler: T): T {
const ref = useRef(handler);
useEffect(() => {
ref.current = handler; // 毎回最新を保存
});
// refを介して呼ぶことで、依存配列に入れずに最新handlerを呼べる
return ((...args: Parameters<T>) => ref.current(...args)) as T;
}
useRefを使うべきか・別フックを選ぶべきかの判断表
状況別の選定フローチャート
最後に、useRef/useState/useMemo/useCallbackの選定を一覧表にまとめました。プルリクのレビュー時にも使える早見表です。
| やりたいこと | 選ぶフック | 理由 |
|---|---|---|
| 画面表示する状態 | useState | 更新で再描画必要 |
| DOM要素を取りたい | useRef | 命令的APIは唯一の方法 |
| 前回値を保持したい | useRef | 再描画不要 |
| タイマーIDを保持 | useRef | clearTimeout用に同一参照 |
| 重い計算結果のキャッシュ | useMemo | 依存配列で再計算制御 |
| 関数の参照固定 | useCallback | 子コンポーネントの再描画抑制 |
| 命令的API公開 | useImperativeHandle | refから限定的に公開 |
FAQ〜実務で頻出する疑問
Q1. useRefの初期値は何回評価されますか?
useRefの初期値は毎レンダリング評価されます(ただしrefの中身は1回目しか使われません)。useRef(new Heavy())のように重い式を書くと無駄が発生するので、上述のuseLazyRefパターンか、関数として渡せるuseRef(() => expensive())……は使えません(useRefは関数を遅延評価しません)。lazy initが必要ならカスタムフック化しましょう。
Q2. ref.currentの変更でなぜ再レンダリングされないのですか?
useRefが返すオブジェクトは毎レンダリングで同一参照です。Reactは「stateの値が変わったか」をObject.isで見て再描画を決めますが、refの.currentはReactの追跡対象外なので、書き換えても何も起きません。これがuseRefの最大の特徴です。
Q3. React 19でforwardRefは完全に削除されますか?
2026年5月時点では非推奨(deprecated)扱いです。今すぐ動かなくなるわけではありませんが、新規コードはprops.refを使い、既存コードは公式codemodで一括変換しておくのが安全です。React 20で削除される可能性があるので、技術的負債として残さない方が無難です。
Q4. useRefとuseStateを両方使う場面はありますか?
はい、頻出します。「画面表示用にstate、内部メモ用にref」の組み合わせがそれです。例:タイマーで1秒ごとカウントアップ(state)するが、開始時刻をrefで保持する、など。同じ値をstateとrefの両方に入れるのは原則NGですが、用途が違えば両方使うのが正解です。
Q5. refをuseEffectの依存配列に入れるとどうなりますか?
何も起きません。refオブジェクトの参照は不変なので、useEffectは初回マウント時に1回しか実行されません。一見問題ないように見えますが、「ref.currentの変化を検知したい」という意図で入れている場合は意図と動作が完全に乖離します。検知したいならstateに切り替えるか、callback refを使ってください。
Q6. useImperativeHandleはいつ使うべきですか?
「子コンポーネントが内部に状態とDOMを抱えていて、親からは特定の操作だけを呼びたい」場合に限定すべきです。具体例:カスタムinputのfocus()・clear()、モーダルのopen()・close()、video pluginのplay()・seek()など。データの取得や状態の同期にはuseImperativeHandleではなく、propsとcallbackで宣言的に書いてください。
Q7. ref.currentがnullになる瞬間はいつですか?
初期値でuseRef<HTMLDivElement>(null)のようにnullを渡している場合、マウント前とアンマウント後はnullです。useEffectの第1引数(マウント後)では非null、useEffectのクリーンアップ関数の実行タイミングではまだ非null(DOMはまだ残っている)、その直後にReactがnullに戻します。クリーンアップ内でref.current?.removeEventListenerが呼べるのはこの仕様のおかげです。
まとめ〜useRefの3用途を体に染み込ませる
本記事では、useRefを「DOM参照・mutable value・stable identity」の3用途に整理し、それぞれについて実務で頻出するパターンをコード30本超で示しました。最後に要点を6つに圧縮します。
- useRefは「再レンダリングをトリガしない箱」。useStateとの境界線は「画面に出すか」で判断する。
- DOM参照は命令的API(focus/scroll/play/canvas等)を呼ぶための唯一の正攻法。
- mutable valueとして、前回値・初回フラグ・隠しカウンタなどを再描画なしで持てる。
- stable identityとして、タイマーID・AbortController・WebSocket・Observerインスタンスを保持し、useEffectのクリーンアップとセットで管理する。
- React 19では
forwardRefが非推奨化。新規はprops.ref、既存はcodemodで段階移行する。 - useImperativeHandleは最後の手段。状態の同期や値の取得には使わず、focus/scroll/play等の命令的APIだけに留める。
useRefを使いこなせるかどうかは、React実装力の中級と上級を分ける明確なラインです。useState・useEffect・useMemo/useCallbackと組み合わせて、宣言的な世界に「命令的な側面」をどう美しく溶け込ませるか――その判断こそが、本物のReactエンジニアの腕の見せどころです。本記事の30本超のコードを手元のエディタにコピペし、自分のプロジェクトで1つずつ動かしてみてください。useRefの3用途が頭ではなく指先に染み込んだとき、Reactコンポーネント設計の解像度が一段階上がっていることに気づくはずです。

コメント