React で DOM 要素を直接触りたいとき、多くの人はまず useRef を思い浮かべます。ですが「要素が画面に現れたその瞬間に処理を走らせたい」というケースでは、useRef だけでは少し扱いにくい場面があります。そこで役立つのがコールバックref(callback ref)です。この記事では、コールバックrefとは何か、useRef の ref とどう違うのか、マウント時のフォーカスや要素の計測といった基本例から、リストの要素を集める実用例、そして「毎レンダーで2回呼ばれる」つまずきポイントまでを、React 関数コンポーネント(TSX)のコード付きで解説します。React 18 までと React 19 で挙動が変わる点にも触れます。
目次
コールバックrefとは:ref に関数を渡す書き方
コールバックrefとは、要素の ref 属性に useRef の戻り値(オブジェクト)ではなく関数を渡す書き方のことです。React はこの関数を、要素を実際の DOM にアタッチしたタイミングで呼び出し、そのときの DOM ノードを引数として渡してくれます。
export function CallbackRefBasic() {
// ref に関数を渡す。node は実際の DOM 要素
return (
<div
ref={(node) => {
// 要素が DOM にアタッチされたときに呼ばれる
console.log('マウントされた要素:', node);
}}
>
こんにちは
</div>
);
}
ここで渡した関数のことを「コールバックref」と呼びます。React はこの関数を、要素をDOMに追加したとき(マウント時)にその DOM ノードを引数にして呼び、要素をDOMから取り除いたとき(アンマウント時)には null を引数にして呼びます(この動作は React 18 まで。React 19 での変更は後述します)。つまり、要素が「付いた瞬間」と「外れた瞬間」の両方を、この1つの関数で受け取れるわけです。
useRef の ref との違い
ではなぜ useRef ではなく関数を渡すのでしょうか。両者の違いを整理すると、次のようになります。
| 渡すもの | 受け取り方 | 「セットされた瞬間」の検知 |
|---|---|---|
useRef の戻り値(オブジェクト) | ref.current に DOM が入る | できない(いつ入ったかは分からない) |
| 関数(コールバックref) | 引数として DOM が渡ってくる | できる(付いた/外れたときに関数が呼ばれる) |
useRef は、DOM を ref.current に「保持しておく」ための入れ物です。React が current に要素を代入してくれますが、代入されたその瞬間を私たちが知る手段はありません。一方コールバックrefは、要素が付いた(または外れた)まさにそのタイミングで関数が呼ばれるため、そこに処理を差し込めます。「保持したいだけ」なら useRef、「付いた/外れた瞬間に何かしたい」ならコールバックref、という使い分けになります。
基本例:マウント時にフォーカスする・幅を測る
入力欄にフォーカスを当てる
もっとも分かりやすい使い道が、要素が現れた瞬間の初期処理です。次の例は、入力欄がマウントされたときに自動でフォーカスを当てるものです。node が渡ってきたら(=要素が付いたら)focus() を呼びます。
export function FocusInput() {
return (
<input
ref={(node) => {
// node が存在する = 要素がマウントされたとき
if (node) {
node.focus();
}
}}
placeholder="ここに自動でフォーカスが当たります"
/>
);
}
アンマウント時には node が null になって呼ばれるため、if (node) で存在を確かめてから触るのが安全です。useRef と useEffect を組み合わせても同じことは実現できますが、コールバックrefなら要素定義のすぐそばに処理を書けて見通しがよくなります。
要素の幅を測る
要素が付いた瞬間にサイズを測りたいときにも向いています。getBoundingClientRect() で幅や高さを取得し、state に保存する例です。
import { useState } from 'react';
export function MeasureWidth() {
const [width, setWidth] = useState<number | null>(null);
return (
<div>
<p
ref={(node) => {
if (node) {
// 要素がマウントされた時点で実寸を測れる
const rect = node.getBoundingClientRect();
setWidth(rect.width);
}
}}
>
この段落の幅を測ります。
</p>
<p>幅: {width !== null ? `${Math.round(width)}px` : '計測中…'}</p>
</div>
);
}
要素が DOM に存在するタイミングで呼ばれるので、getBoundingClientRect() が正しい値を返します。マウント直後の初期サイズを一度だけ知りたい、といった用途にぴったりです。
実用例:リストの要素を Map に集めてスクロールする
コールバックrefが本領を発揮するのは、動的に増減する複数の要素を扱うときです。useRef は基本的に1つの値しか持てないため、数が変わるリストの各要素を個別に参照したい場合は不便です。そこで、各要素のコールバックrefで DOM を Map に集めておく方法がよく使われます。
次の例は、リストの各項目を Map に登録し、ボタンを押すと最後の項目までスクロールするものです。要素が付くときは Map に追加し、外れるとき(node が null)は Map から削除します。
import { useRef } from 'react';
const items = Array.from({ length: 30 }, (_, i) => `項目 ${i + 1}`);
export function ScrollableList() {
// key(文字列) と DOM ノードの対応を Map で保持する
const nodeMap = useRef(new Map<string, HTMLLIElement>());
const scrollToLast = () => {
const last = items[items.length - 1];
const node = nodeMap.current.get(last);
node?.scrollIntoView({ behavior: 'smooth' });
};
return (
<div>
<button onClick={scrollToLast}>最後までスクロール</button>
<ul style={{ height: 200, overflowY: 'scroll' }}>
{items.map((item) => (
<li
key={item}
ref={(node) => {
if (node) {
// 要素が付いたら Map に登録
nodeMap.current.set(item, node);
} else {
// 外れたら Map から削除
nodeMap.current.delete(item);
}
}}
>
{item}
</li>
))}
</ul>
</div>
);
}
ここでは Map 自体を useRef で保持し、そこへの出し入れをコールバックrefで行っている点がポイントです。要素の数が変わっても、各要素の付け外しに合わせて Map が自動的に最新の状態に保たれます。特定の項目までスクロールする、フォーカスを移す、位置を測るといった「複数要素を個別に操作したい」場面で定番の書き方です。
毎レンダーで2回呼ばれてしまうとき
コールバックrefで最初につまずきやすいのが、「思っていないタイミングで何度も呼ばれる」現象です。原因を知らないと、フォーカスが外れる・計測処理が余計に走るといった不具合につながります。
インライン関数は毎レンダー作り直される
これまでの例のように ref={(node) => { ... }} とインラインで関数を書くと、コンポーネントが再レンダリングされるたびに新しい関数が作られます。React は「ref に渡された関数が前回と別物になった」と判断すると、いったん古い関数を null で呼んで(後片付け)から、新しい関数を node で呼び直します。つまり再レンダーのたびに「null で1回 → node で1回」と、実質2回呼ばれることになります。
単純に DOM を Map に入れ直すだけなら問題になりませんが、コールバックの中で重い計算をしたり、フォーカスを当て直したりしていると、更新のたびに余計な処理が走って不都合が出ます。
useCallback で関数を安定させる
この余計な呼び出しを避けたいときは、コールバックrefを useCallback でメモ化し、レンダーをまたいで同じ関数(同一の参照)を渡すようにします。関数が前回と同じなら、React はそれを「変わっていない」とみなし、null → node の呼び直しを行いません。
import { useCallback, useState } from 'react';
export function StableCallbackRef() {
const [count, setCount] = useState(0);
// 依存配列が空なので、関数は初回に作られたものが使い回される
const setInputRef = useCallback((node: HTMLInputElement | null) => {
if (node) {
// ここは再レンダーのたびには呼ばれなくなる
node.focus();
}
}, []);
return (
<div>
<input ref={setInputRef} />
<button onClick={() => setCount(count + 1)}>
再レンダー(count: {count})
</button>
</div>
);
}
この例では、ボタンを押して再レンダーが起きても setInputRef は同じ関数のままなので、マウント時に一度フォーカスが当たった後は、余計に呼び直されません。「マウント時に一度だけ実行したい処理」をコールバックrefに書くときは、useCallback で安定させるのが定石です。逆に、Map への登録のように何度呼ばれても支障のない処理なら、インライン関数のままでも構いません。
React 19 ではクリーンアップ関数を返せる
ここまで説明した「アンマウント時に null で呼ばれる」挙動は React 18 までのものです。React 19 からは、コールバックrefがクリーンアップ関数を返せるようになりました。useEffect と同じように、後片付けの処理を戻り値の関数にまとめて書けます。
export function CleanupRef() {
return (
<div
ref={(node) => {
// マウント時の処理
console.log('マウント:', node);
// React 19: クリーンアップ関数を返せる
return () => {
// アンマウント時の後片付けはここに書く
console.log('アンマウント');
};
}}
>
React 19 のコールバックref
</div>
);
}
重要なのは、クリーンアップ関数を返した場合、アンマウント時に null を引数とした呼び出しは行われなくなるという点です。返した関数が代わりに呼ばれます。付けるときと外すときの処理が1か所にまとまるため、if (node) と else で分岐していた従来の書き方よりも見通しがよくなります。
このように、コールバックrefは使っている React のバージョンによって挙動が異なります。React 18 まではクリーンアップ関数に対応しておらず、アンマウント時は null で呼ばれます。React 19 ではクリーンアップ関数を返せて、返した場合は null では呼ばれません。既存コードを 19 に上げる際は、null 前提で書いた後片付けが期待どおり動くかを確認しておくと安心です。
まとめ
コールバックrefは、要素の ref 属性に関数を渡し、要素が DOM に付いた/外れたタイミングで処理を差し込む仕組みです。useRef が .current に値を保持するだけでセットの瞬間を検知できないのに対し、コールバックrefはマウント時に node を、アンマウント時に null を引数として関数を呼びます(React 18 まで)。フォーカスやサイズ計測といった初期処理、動的なリストの要素を Map に集めて特定要素へスクロールする、といった用途に向いています。注意点は、インライン関数だと毎レンダーで作り直され「null → node」で呼び直されること。一度だけ実行したい処理は useCallback で関数を安定させます。React 19 ではクリーンアップ関数を返せるようになり、その場合は null での呼び出しが行われなくなる点も押さえておきましょう。