【React】複数 useRef を使いたくなったときは ref callback function を使おう
■ はじめに
複数のDOM
要素を参照するためにuseRef
を何回も利用したくなる場面がありました。
そのときに、ref callback function
というものを利用していい感じに実装できたので、その備忘録と知識の深掘りです。
◎ やりたかったこと
やりたかったこととしては、
件数が可変のリストのアイテム要素を指定して、その要素までスクロールして、フォーカスを当てるという動作です。
ヘッダーの数値ボタンをクリックしてその数値のアイテムまでスクロール+フォーカスさせる感じ
この要件を満たすために、事前にリストの件数を知らない状態で実装しなければいけません。
今回はこちらの React 公式
のサンプルコードを参考にしています。
サンプルを実装しながら解説していきます。
リポジトリはこちら(汚いコメントなども残ってますが、よろしければ見てみてください)
ref callback function
とは?
■ まずref callback function
とは
div
等要素のref属性
にrefオブジェクト
ではなく、関数を渡したときのものです。
<div ref={(node: HTMLDivElement | null) => console.log("div ref callback node", node)} />
ref callback function
が呼び出されるタイミングと渡される引数
◎ ref callback function
は呼び出されるタイミングにより、渡される引数が異なります。
React 公式と実際の動作を確認した限り下記のようになるようです。
- DOM要素が画面に表示されたとき
Node
を引数として呼び出し - DOM要素が画面から削除されたとき
null
を引数として呼び出し -
再レンダリングされたとき(※
ref callback function
が異なる参照を持つ場合)- 前の参照をもつ関数に対して
null
を引数として呼び出される - 次の新しいの参照をもつ関数に対して
Node
を引数として呼び出される
- 前の参照をもつ関数に対して
※div
を例にとると DOM要素=div
要素, Node
=HTMLDivElement
と読み替えることができます
ref callback function
が異なる参照を持つ場合のみ呼び出される
◯再レンダリングされたときはつまり、
次の例だと再レンダリングのたびにref callback function
が呼び出されます。
これは、再レンダリング時に毎回関数が生成されて異なる参照先を保持する関数を渡しているためです。
<div ref={(node: HTMLDivElement | null) => console.log("div ref callback node", node)} />
再レンダリングのたびにコンソールが吐かれており、
1回目
DOM要素が画面に表示されたとき
Node
を引数として呼び出し
2回目, 3回目
再レンダリングされたとき(※
ref callback function
が異なる参照を持つ場合)
- 前の参照をもつ関数に対して
null
を引数として呼び出される- 次の新しいの参照をもつ関数に対して
Node
を引数として呼び出される
のタイミングで呼び出されています。
次の例だと再レンダリング時にはref callback function
呼び出されません。
これは、関数をメモ化することにより、異なる参照先になることを防いでいるためです。
const refCallbackFn = useCallback(
(node: HTMLDivElement | null) => console.log("div ref callback node", node),
[]
);
// ...
<div ref={refCallbackFn} />
再レンダリング時にはコンソールを吐かず、
DOM要素が画面に表示されたとき
Node
を引数として呼び出し
のタイミングだけで呼び出されています。
■ 実装していく
RefObject
を作成
◎ リストアイテム要素を参照するためのまずは、リストアイテム要素を参照するためのRefObject
を定義します。
ここのkey
にリストアイテムのIDが入り、対象のHTMLLIElement
をvalue
に持ちます。
const listItemRefs = useRef<{
[key in number]: HTMLLIElement;
}>({});
こんな形で持たせる。
console.log("listItemRefs", listItemRefs.current);
◎ リストアイテム要素を取得して ref にセットする
ref callback function
に渡す関数を作成して、配列の要素に値の1つとしてもたせます。
この関数で対象のリストアイテム要素のNode
を受け取り、セットしていきます。
node
がnull
で取得される場合にdelete listItemRefs.current[id];
としているのは、
参照不要になった値をlistItemRefs
(RefObject
)に保持するのを防ぐためにおこなう処理です。
/**
* refCallbackFunction を持つ形に配列を整形
* useMemo でメモ化しつつ、refCallbackFunction を配列要素の値として持たせることで
* 関数の参照先が意図せず変更されることを防いでいる
*/
const convertedList = useMemo(
() =>
list.map((v) => ({
id: v.id,
refCallbackFunction: (node: HTMLLIElement | null) => {
if (node !== null && listItemRefs.current[v.id] === undefined) {
// node が null でなく、かつ、ref が未登録の場合
listItemRefs.current[v.id] = node;
} else {
// node が null の場合は、対象の node を管理する必要がなくなるため削除
delete listItemRefs.current[v.id];
}
},
})),
[list]
);
Node
を取得するために配列に持たせた関数をref
にコールバック関数として渡します。
{convertedList?.map((v) => (
<li key={v.id} ref={v.refCallbackFunction} tabIndex={0}>
{v.id}
</li>
))}
◎ ボタンをクリックしたときに対象のリストアイテム要素を参照して、イベントを発火する
クリックしたときに、id
からlistItemRefs
(RefObject
)の値であるHTMLLIElement
が取得できるので、そのHTMLLIElement
に対して
実行したい処理であるスクロールイベントとフォーカスイベントを発火させます。
const handleClickHeaderItem = useCallback((id: number) => {
// id から 対象の ref を取得
const itemRef = listItemRefs.current[id];
itemRef?.scrollIntoView({
behavior: "smooth",
block: "center",
});
itemRef?.focus();
}, []);
handleClickHeaderItem
をヘッダーのボタンにセットする
{/* ヘッダー */}
<header>
{list?.map((v) => (
<button key={v.id} onClick={() => handleClickHeaderItem(v.id)}>
{v.id}
</button>
))}
</header>
コードの全体像
function Component({ list }: { list: { id: number }[] }) {
// Mapped Types で型定義した RefObject を作成
const listItemRefs = useRef<{
[key in number]: HTMLLIElement;
}>({});
/**
* refCallbackFunction を持つ形に配列を整形
* useMemo でメモ化しつつ、refCallbackFunction を配列要素の値として持たせることで
* 関数の参照先が意図せず変更されることを防いでいる
*/
const convertedList = useMemo(
() =>
list.map((v) => ({
id: v.id,
refCallbackFunction: (node: HTMLLIElement | null) => {
if (node !== null && listItemRefs.current[v.id] === undefined) {
// node が null でなく、かつ、ref が未登録の場合
listItemRefs.current[v.id] = node;
} else {
// node が null の場合は、対象の node を管理する必要がなくなるため削除
delete listItemRefs.current[v.id];
}
},
})),
[list]
);
const handleClickHeaderItem = useCallback((id: number) => {
// id から 対象の ref を取得
const itemRef = listItemRefs.current[id];
itemRef?.scrollIntoView({
behavior: "smooth",
block: "center",
});
itemRef?.focus();
}, []);
return (
<div>
{/* ヘッダー */}
<header>
{list?.map((v) => (
<button key={v.id} onClick={() => handleClickHeaderItem(v.id)}>
{v.id}
</button>
))}
</header>
{/* リスト */}
<ul>
{convertedList?.map((v) => (
<li key={v.id} ref={v.refCallbackFunction} tabIndex={0}>
{v.id}
</li>
))}
</ul>
</div>
);
}
■ アンチパターン(よろしくない実装)
useRef
をおこなうパターン
◎ ループ処理内でリストをもとにmap
のなかでuseRef
を実行したくなります。
しかし、当然ながらReact hooks
のルールに反するので怒られます。
const convertedList = list.map((item) => ({
id: item.id,
ref: useRef(null),
}));
React Hook "useRef" cannot be called inside a callback.
React Hooks must be called in a React function component or a custom React Hook function.
(react-hooks/rules-of-hooks)
createRef
という関数を利用してもできそうではあるものの、createRef
はクラスコンポーネントでの利用を前提としており、関数コンポーネントでの利用はよくなさそうです。
// 関数コンポーネントでの利用はよくない
const convertedList = list.map((item) => ({
id: item.id,
ref: createRef<HTMLLIElement>(),
}));
◎ 親要素から子要素を参照するパターン
次に親要素への 1 つの ref を取得して、子要素を参照するやり方が考えられます。
しかし、この方法ではDOM構造が変更されると正常に動作しない可能性があり、推奨されていません。
const ulRef = useRef<HTMLUListElement>(null);
const handleClickHeaderItem = useCallback((id: number) => {
// 親要素から子要素をidで取得する
const itemRef: HTMLLIElement | null | undefined =
ulRef.current?.querySelector("#id" + id.toString());
itemRef?.scrollIntoView({
behavior: "smooth",
block: "center",
});
itemRef?.focus();
}, []);
// ...
<Ul ref={ulRef}>
{list?.map((v) =>
hideItemIds.includes(v.id) ? null : (
<Li
key={v.id}
id={"id" + v.id.toString()} // id を付与
...
■ さいごに
最後までお読みいただきありがとうございました!
Discussion