0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React `ref`の教訓

Posted at

この記事は何

React のrefを使うにあたって個人が経験したことをまとめた記事です。
refを使う上での注意とエッジケースに対応した使用例をまとめました。

ref を参照していい(いけない)タイミング

参考: https://react.dev/learn/manipulating-the-dom-with-refs#when-react-attaches-the-refs

refは DOM を参照している場合 rendering 後でのみ参照すること。
レンダリング中はrefを参照してはならない。

以下説明:

React のレンダリングの流れは以下のとおりである。

  1. レンダリングのトリガーの発生
  2. コンポーネントのレンダリング
  3. レンダリング内容の DOM への反映

(https://react.dev/learn/render-and-commit)

ここで、「2. コンポーネントのレンダリング」のレンダリングの意味は、React コンポーネントの再呼出のことであると書かれている。

After you trigger a render, React calls your components to figure out what to display on screen. “Rendering” is React calling your components.

なので、コンポーネントの再呼出(再計算)によって導き出された戻り値を、DOM に反映させるのが「3. レンダリング内容の DOM への反映」である。

このとき変更の反映対象となる DOM は前回と違いが発生する DOM のみである。

ということで(当然であるが)DOM は毎レンダリング変化する可能性がある。
そして、この毎度のレンダリングにまたがって値を保持してくれるのがrefである。

DOM は毎レンダリングで変更される可能性があるが、一方でrefは毎レンダリングにまたがって値を保持する機能である。

refを DOM を参照するように使っているとき、refは変更がコミットされた DOM を参照している。

refは本来変更される前の値を保持し続けるはずであるが、いつ更新された DOM を参照しなおすのか?

DOM にアタッチされているrefがレンダリングプロセスの間どうなっているのかの状況が次の通り:

  1. 初期レンダリング時:DOM はまだ作成されていないから ref は null
  2. 毎レンダリング時: ref は更新されない
  3. コミット時(変更を DOM に適用するとき): ref は DOM にアタッチされる(ref.current へ DOM が渡される)

(https://react.dev/learn/manipulating-the-dom-with-refs#when-react-attaches-the-refs)

ということで、refは DOM の参照として渡されると、レンダリング中はいったん null にされて、コミット段階の DOM への変更の提要が終わってから対象の DOM が改めて渡されるようである。

つまり、

レンダリング中にrefを読み取ったり書き込んだりしてはならない。

上述の通りレンダリング中は変更がまだ適用されていないからである。DOM を参照する使い方に限らず、refはレンダリング中に参照してはならない。

refは React の理の外にあるものを指すためのエスケープ・ハッチである。

ということで、refは副作用かイベントハンドラのタイミングでのみ使うべきである。

参考:

ref を使う上で知っておくこと

  • ref は、明示的に変更しない限りレンダリングにまたがって値を記憶する
  • ref.current は、state と異なり変更されても再レンダリングをトリガーしない(React は関知しない)
  • ref.currentの値をレンダリング中に読み取ってはならない。レンダリング中に ref はまだ更新されていない
  • useEffect(, [ref.current])はご法度である。理由は ref.current の変更は再レンダリングをトリガーせず、予期しないタイミングでuseEffect(, [ref.current])が実行される可能性があるからである

useEffect()はそのうちに使う変数などは依存関係に含めないといけないが、ref.currentは例外といえるようで、ref.currentを依存関係に含めなくても使うことができる。
(https://react.dev/learn/synchronizing-with-effects#why-was-the-ref-omitted-from-the-dependency-array)

参考:

useEffectの依存関係にref.currentだけ渡すのは危険

ref参照対象の変化を監視したいときに考え付く方法とは以下のものであると思う。

React.useRefを使って ある対象を参照しておき、
ref.currentに変更があったらuseEffect(, [ref.current])で変更を検知しようという考えである。

しかしそれは失敗する。
理由はref.currentの変更は再レンダリングを引き起こさないから。

もしuseEffectの依存関係に含めたとしても、ref.currentの変更によってuseEffectが呼び出されず更新のトリガーになりえないからである。

これをやってしまうと、別のトリガーで引き起こされた再レンダリング後のタイミングでuseEffect(,[ref.current])が実行されてしまう。

任意の button をクリックするとその button の番号を ref に渡し、その変更を副作用で取得しようとして失敗しているコード

import React, { useRef, useEffect, useState } from "react";

const DontPassUnstaticRefAsDependency = () => {
  const refButtonId = useRef<number>(0);

  useEffect(
    () => {
      console.log(`ref.current has been changed to ${refButtonId.current}`);
    },
    [refButtonId.current]
  );

  const handleClick = (num: number) => {
    console.log(`Clicked ${num}`);
    refButtonId.current = num;
  };

  console.log("rendering");

  return (
    <div style={{ position: "relative" }}>
      <button onClick={() => handleClick(1)}>Pass 1 to ref</button>
      <button onClick={() => handleClick(2)}>Pass 2 to ref</button>
      <button onClick={() => handleClick(3)}>Pass 3 to ref</button>
      <button onClick={() => handleClick(4)}>Pass 4 to ref</button>
    </div>
  );
};

refButtonId.currenthandleClickで変更されてもuseEffect(,[refButtonId.current])は実行されない。

refの参照対象の変更は再レンダリングをトリガーしないからである。

そりゃそうだという話ですが、

このコードの困る点は、ほかのトリガーによる再レンダリング発生時にrefButtonId.currentの値のみを取得することになる点である。

常に button クリックによる変更を知らなきゃならないという状況の時は、そのトリガーが発生するまでに行ったすべてのrefButtonId.currentの変更は失われるのである。

useEffectrefの効果的な使い方

先の問題の解決策を探る。

useEffectの使い方として、その内部で参照する値はすべて依存関係に含めなくてはならないというルールがあるが、

依存関係に含めるべき変数が「常に安定した値」を返す場合は省略していいようだ。

This is because the ref object has a stable identity: React guarantees you’ll always get the same object from the same useRef call on every render. It never changes, so it will never by itself cause the Effect to re-run. Therefore, it does not matter whether you include it or not. Including it is fine too:

となると、

ref.currentがずっと変化しなければ依存関係に含める必要がなく、変化するようなことがあれば含めなくてはならない。

解決案:ref.currentの変更時に再レンダリングをトリガーさせる

ref.currentの参照対象を変更するようなことがある場合はuseEffectの依存関係にref.currentに依存関係を含めなくてはならないというのがルールとのことなので、useEffectの扱いはそうしないといけないとして、

とはいえref.currentの変更は再レンダリングをトリガーしないのは事実である。

そこで、ref.currentの変更対象が変更されたら再レンダリングをトリガーするようにすればよいのである。

ref.current の変更と setState の実行を必ずセットにするのである。


const DontPassUnstaticRefAsDependency = () => {
  const refButtonId = useRef<number>(0);
  const [currentNum, setCurrentNum] = useState<number>(0);

  useEffect(() => console.log("did uodate"));

  useEffect(
    () => {
      console.log(
        `refButtonId.current has been updated: ${refButtonId.current}`
      );
      console.log(`currentNum: ${currentNum}`);
    },
    // ref.currentの変更とsetStateをセットにすることを前提にすれば
    // ref.current単体を依存関係に含めても問題ない
    [refButtonId.current]
  );

  const handleClick = (num: number) => {
    console.log(`Clicked ${num}`);
    // ref.currentの変更とsetStateをセットにする
    refButtonId.current = num;
    setCurrentNum(num);
  };

  console.log("rendering");

  return (
    <div style={{ position: "relative" }}>
      <button onClick={() => handleClick(1)}>Pass 1 to ref</button>
      <button onClick={() => handleClick(2)}>Pass 2 to ref</button>
      <button onClick={() => handleClick(3)}>Pass 3 to ref</button>
      <button onClick={() => handleClick(4)}>Pass 4 to ref</button>
        force rerender
      </button>
    </div>
  );
};

結果:

# StrictModeです
# mount時
rendering
did update
refButtonId.current has been updated: 0
currentNum: 0
did update
refButtonId.current has been updated: 0
currentNum: 0
# button 2をクリックした
Clicked 2     # setCurrentNum(2)して
rendering     # 再レンダリングが発生し
did update    # useEffect(,[refButtonId.current])が実行される
refButtonId.current has been updated: 2
currentNum: 2
# button 3をクリックした
# 以下同様
Clicked 3
rendering
did update
refButtonId.current has been updated: 3
currentNum: 3

今回の例はbutton idを state 管理すればいいだけでは?という拙い例であるが、

refの参照が変化したことを検知したい状況に対しては使える例と考える。

一つのコンポーネントに複数 ref を渡したいとき

例:親コンポーネントから ref を取得し、なおかつ自コンポーネントも useRef()で ref を使いたいとき

親コンポーネントは、常に最新の自コンポーネントのDOMRectElement.scrollWidth情報を必要としているとする。

一方自コンポーネントはuseRefを使っている。

親コンポーネントも自コンポーネントもどちらも同じ DOM を参照させなくてはならない。

そんなとき。

interface iProps {
  // 親コンポーネントからのref
  _ref: React.RefObject<HTMLDivElement>;
  // div.tabの数
  numberOfTabs: number;
}

const Tabs = ({_ref, numberOfTabs}: iProps) => {
  const [selected, setSelected] = useState<number>(1);
  // 自コンポーネントが使っているref
  const _refTabArea = useRef<HTMLDivElement>(null);
  // div.tabの各要素を参照するrefの配列
  const _refTabs = useRef(
    Array.from({ length: numberOfTabs }, (_, i) => i + 1).map(() =>
      React.createRef<HTMLDivElement>()
    )
  );

  // _refTabAreaはどのタブがクリックされたのか調べるときに使う
  const changeTab = (selectedTabNode: HTMLDivElement) => {
    // 一旦すべてのtabのclassNameを'tab'にする
    for (var i = 0; i < _refTabArea.current!.childNodes.length; i++) {
      var child: iJSXNode = _refTabArea.current!.childNodes[i];
      if (/tab/.test(child.className!)) {
        child.className = "tab";
      }
    }
    // 選択されたtabのみclassName='tab active'にする
    selectedTabNode.className = "tab active";
  };

  return (
    <div
      className="tabs-area"
      ref={_refTabArea}
      // 両方渡す方法は...
      // ref={_ref}
      style={stylesOfTabsArea}
    >
      {Array.from({ length: numberOfTabs }, (_, i) => i + 1).map((i, index) => (
        <div
          className={index === selected ? "tab active" : "tab"}
          key={index}
          style={stylesOfTab}
          ref={_refTabs.current[index]}
          onClick={() =>
              changeTab(_refTabs.current[index].current!)
          }
        >
          <span>tab {i}</span>
        </div>
      ))}
    </div>
  );
};

アプローチ1:useImperativeHandleフックを使う方法

useImperativeHandleは、親コンポーネントから渡された ref に対して、DOM を渡す代わりに自コンポーネントのスコープを持つ関数を渡す代物である。

メリット:

  • 親コンポーネントからの ref を DOM に渡す必要がないので子コンポーネントは自分の ref を使うことができる
  • ref は props 経由で渡された ref でもいいので、孫コンポーネント以下へ渡すことも可能
  • 親コンポーネントは ref の呼び出しを任意のタイミングにできる

デメリット:

  • 子コンポーネントは親コンポーネントからの要求を知らなくてはならない

interface iProps {
  // useImperativeHandleのコールバックの型に合わせる
  _ref: React.RefObject<{
    getTabsAreaRect: () => DOMRect | undefined;
    getScrollWidth: () => number;
  }>;
  numberOfTabs: number;
}

const Tabs = ({ _ref, numberOfTabs }: iProps) => {
  const [selected, setSelected] = useState<number>(1);
  const _refTabArea = useRef<HTMLDivElement>(null);
  const _refTabs = useRef(
    Array.from({ length: numberOfTabs }, (_, i) => i + 1).map(() =>
      React.createRef<HTMLDivElement>()
    )
  );

  // 親コンポーネントから受け取ったrefはここに渡す
  useImperativeHandle(
    _ref,
    () => {
      return {
        getTabsAreaRect() {
          if (_refTabArea.current) {
            return _refTabArea.current.getBoundingClientRect();
          } else return undefined;
        },
        getScrollWidth() {
          if (_refTabArea.current) {
            return _refTabArea.current.scrollWidth;
          } else return undefined;
        }
      };
    },
    []
  );

  const changeTab = (selectedTabNode: HTMLDivElement, index: number) => {
    // ...
  };

  return (
    <div className="tabs-area"
      // 自コンポーネントのrefを維持できる
      ref={_refTabArea}
    >
      {Array.from({ length: numberOfTabs }, (_, i) => i + 1).map((i, index) => (
        // ...
      ))}
    </div>
  );
};

今回のコードはリアクティブな値であるrefTabAreaを依存関係に含めていない。

理由は先の方で述べた通り、ref が安定して同じ値を指し続ける場合は省略可能であるため。

アプローチ2:Callback refを使う方法

つまり、親からの ref と自身の ref を callback ref のコールバック関数内で呼び出すことで両方に DOM を渡すのである

やること:

  • ref を渡したい対象の ref にはCallback refを渡す
  • 渡せる ref の型は undefined を受け入れるようにする。React.MutableRefObject<HTMLDivElement | undefined>

Callback refは毎レンダリング時に必ず呼び出されるので、ref.currentは毎レンダリング時に更新されることになるが

結局ずっと同じ対象を参照するのでuseEffect(,[ref.current])しなければ無駄な処理は起こらない。


interface iProps {
  _ref: React.MutableRefObject<HTMLDivElement | undefined>;
  numberOfTabs: number;
}

const Tabs = ({ _ref, numberOfTabs }: iProps) => {
  const [selected, setSelected] = useState<number>(1);
  // undefinedを受け入れさせる 且つ nullを渡さない
  const _refTabArea = useRef<HTMLDivElement | undefined>();
  const _refTabs = useRef<HTMLDivElement[]>([]);

  // ...

  return (
    <div
      className="tabs-area"
      // Callback refを渡す
      ref={(node: HTMLDivElement) => {
        // callback内でnodeを渡す
        _refTabArea.current = node;
        _ref.current = node;
      }}
      style={stylesOfTabsArea}
    >
      {Array.from({ length: numberOfTabs }, (_, i) => i + 1).map((i, index) => (
        <div
          className={index === selected ? "tab active" : "tab"}
          key={index}
          style={stylesOfTab}
          ref={(node: HTMLDivElement) => (_refTabs.current[index] = node)}
          onClick={() => changeTab(_refTabs.current[index], index)}
        >
          <span>tab {i}</span>
        </div>
      ))}
    </div>
  );
};

export default Tabs;

React.RefObject<HTMLDivElement>にするとref.currentは読み取り専用だからできませんエラーが出る。

子または孫以下のコンポーネントへ ref を渡す方法

公式曰く、

  • 直接の子コンポーネントならforwardRef
  • 孫以下なら別に ref を props 経由でバケツリレーしてかまわない

動的配列で生成される各コンポーネント全てに ref を渡したいとき

  • ref配列の長さはuseEffectで更新する
  • 動的配列から生成される各要素にはref={_refTabs.current[index]}という方法で ref を渡す
interface iProps {
  // div.tabsの数。TabsはnumberOfTabsを元に表示div.tab数を決定する
  numberOfTabs: number;
}

const Tabs = ({ numberOfTabs }: iProps) => {
  const _refTabArea = useRef<HTMLDivElement>(null);
  const _refTabs = useRef<HTMLDivElement[]>([]);

  // _refTabs.currentのref配列の数をnumberOfTabsに一致するように再計算するための更新
  useEffect(() => {
    if (_refTabs.current) {
      _refTabs.current = _refTabs.current.slice(0, numberOfTabs);
    }
  }, []);

  // _refTabs.currentのref配列の数をnumberOfTabsに一致するように再計算するための更新
  useEffect(() => {
    if (_refTabs.current) {
      _refTabs.current = _refTabs.current.slice(0, numberOfTabs);
    }
  }, [numberOfTabs]);

  //...

  // DOM情報を使ってdiv.tabのclassNameを変更する関数
  const changeTab = (selectedTabNode: HTMLDivElement, index: number) => {
    for (var i = 0; i < _refTabArea.current!.childNodes.length; i++) {
      var child: iJSXNode = _refTabArea.current!.childNodes[i];
      if (/tab/.test(child.className!)) {
        child.className = "tab";
      }
    }
    selectedTabNode.className = "tab active";
  };

  return (
    <div className="tabs-area" ref={_refTabArea} >
      {Array.from({ length: numberOfTabs }, (_, i) => i + 1).map((i, index) => (
        <div
          className={index === selected ? "tab active" : "tab"}
          key={index}
          style={stylesOfTab}
          ref={(node: HTMLDivElement) => (_refTabs.current[index] = node)}
          onClick={() => changeTab(_refTabs.current[index], index)}
        >
          <span>tab {i}</span>
        </div>
      ))}
    </div>
  );
};
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?