この記事は何
React のref
を使うにあたって個人が経験したことをまとめた記事です。
ref
を使う上での注意とエッジケースに対応した使用例をまとめました。
ref を参照していい(いけない)タイミング
参考: https://react.dev/learn/manipulating-the-dom-with-refs#when-react-attaches-the-refs
ref
は DOM を参照している場合 rendering 後でのみ参照すること。
レンダリング中はref
を参照してはならない。
以下説明:
React のレンダリングの流れは以下のとおりである。
- レンダリングのトリガーの発生
- コンポーネントのレンダリング
- レンダリング内容の 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
がレンダリングプロセスの間どうなっているのかの状況が次の通り:
- 初期レンダリング時:DOM はまだ作成されていないから ref は null
- 毎レンダリング時: ref は更新されない
- コミット時(変更を 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.current
がhandleClick
で変更されてもuseEffect(,[refButtonId.current])
は実行されない。
ref
の参照対象の変更は再レンダリングをトリガーしないからである。
そりゃそうだという話ですが、
このコードの困る点は、ほかのトリガーによる再レンダリング発生時にrefButtonId.current
の値のみを取得することになる点である。
常に button クリックによる変更を知らなきゃならないという状況の時は、そのトリガーが発生するまでに行ったすべてのrefButtonId.current
の変更は失われるのである。
useEffect
とref
の効果的な使い方
先の問題の解決策を探る。
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 を使いたいとき
親コンポーネントは、常に最新の自コンポーネントのDOMRect
とElement.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>
);
};