🙆‍♀️

【React】複数 useRef を使いたくなったときは ref callback function を使おう

2023/07/02に公開

■ はじめに

複数のDOM要素を参照するためにuseRefを何回も利用したくなる場面がありました。
そのときに、ref callback functionというものを利用していい感じに実装できたので、その備忘録と知識の深掘りです。

https://react.dev/reference/react-dom/components/common#ref-callback

◎ やりたかったこと

やりたかったこととしては、
件数が可変のリストのアイテム要素を指定して、その要素までスクロールして、フォーカスを当てるという動作です。

ヘッダーの数値ボタンをクリックしてその数値のアイテムまでスクロール+フォーカスさせる感じ

この要件を満たすために、事前にリストの件数を知らない状態で実装しなければいけません。

今回はこちらの React 公式のサンプルコードを参考にしています。
https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback

サンプルを実装しながら解説していきます。
リポジトリはこちら(汚いコメントなども残ってますが、よろしければ見てみてください)
https://github.com/TomoyukiMatsuda/React-pra/blob/master/otamesiproject/src/pages/multiple-use-ref-scroll/index.tsx

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が異なる参照を持つ場合)
    1. 前の参照をもつ関数に対してnullを引数として呼び出される
    2. 次の新しいの参照をもつ関数に対して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が異なる参照を持つ場合)

  1. 前の参照をもつ関数に対してnullを引数として呼び出される
  2. 次の新しいの参照をもつ関数に対して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が入り、対象のHTMLLIElementvalueに持ちます。

const listItemRefs = useRef<{
    [key in number]: HTMLLIElement;
  }>({});

こんな形で持たせる。

console.log("listItemRefs", listItemRefs.current);

◎ リストアイテム要素を取得して ref にセットする

ref callback functionに渡す関数を作成して、配列の要素に値の1つとしてもたせます。
この関数で対象のリストアイテム要素のNodeを受け取り、セットしていきます。

nodenullで取得される場合に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)

https://react.dev/warnings/invalid-hook-call-warning

createRefという関数を利用してもできそうではあるものの、createRefはクラスコンポーネントでの利用を前提としており、関数コンポーネントでの利用はよくなさそうです。

// 関数コンポーネントでの利用はよくない
const convertedList = list.map((item) => ({
    id: item.id,
    ref: createRef<HTMLLIElement>(),
  }));

https://react.dev/reference/react/createRef

◎ 親要素から子要素を参照するパターン

次に親要素への 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