※こちらの記事はTanstack Queryのmaintanierでもあるtkdodoさんの記事Avoiding useEffect with callback refsの和訳になります。著者の許可を得て掲載しています。
注:この記事は、Reactにおけるrefsが何であるかについての基本的な理解を前提としています。
refsは理論的には任意の値を格納できるミュータブルコンテナですが、最もよく使われるのはDOMノードにアクセスするためです。
const ref = React.useRef(null)
return <input ref={ref} defaultValue="Hello world" />
refはビルドインプリミティブの予約プロパティで、Reactがレンダリング後のDOMノードを保存する場所です。コンポーネントがアンマウントされると、NULLに戻されます。
Interacting with refs
ほとんどのインタラクションでは、Reactが自動的に更新を処理してくれるため、基礎となるDOMノードにアクセスする必要はありません。React が自動的に更新を行うからです。refが必要になる良い例が、フォーカス管理です。
Devon Govettによる良いRFCがあり、react-domにFocusManagementを追加することが提案されていますが、今のところReactにはそれを手助けしてくれるものは何もありません。
Focus with an effect
では、今現在、レンダリング後の入力要素にどのようにフォーカスを当てるのでしょうか?(オートフォーカスがあることは知っていますが、これは一例です。もしこれが気になるなら、代わりにノードをアニメーションさせることを想像してみてください)。
私が見たほとんどのコードでは、このようにしてこれを行おうとしています。
const ref = React.useRef(null)
React.useEffect(() => {
ref.current?.focus()
}, [])
return <input ref={ref} defaultValue="Hello world" />
これはほとんど問題なく、何のルール違反にもなりません。空の依存関係配列は、中で使われているのがrefだけなので、安定していて大丈夫です。リンターは依存配列に追加しても文句を言いませんし、レンダリング中にrefが読み込まれることもありません(これはReactのconcurrent 機能では面倒なことになるかもしれません)。
この効果は「マウント時」に一度だけ実行されます(strict modeでは二度)。その時点でReactはすでにDOMノードでrefをpopulateしているので、それにフォーカスすることができます。
しかし、これは最良の方法ではなく、より高度な状況においてはいくつかの注意点があります。
具体的にはEffectが実行されるときにrefが「満たされている(filled)」ことを前提としています。例えば、レンダリングを遅延させるカスタムコンポーネントに ref を渡したり、他のユーザ操作の後にのみinputを表示させたりして、それが利用できない場合、エフェクトの実行時には ref のコンテンツはまだ null で、フォーカスされるものは何もありません。
function App() {
const ref = React.useRef(null)
React.useEffect(() => {
// 🚨 ref.current is always null when this runs
ref.current?.focus()
}, [])
return <Form ref={ref} />
}
const Form = React.forwardRef((props, ref) => {
const [show, setShow] = React.useState(false)
return (
<form>
<button type="button" onClick={() => setShow(true)}>
show
</button>
// 🧐 ref is attached to the input, but it's conditionally rendered
// so it won't be filled when the above effect runs
{show && <input ref={ref} />}
</form>
)
})
以下が起こっていることです。
フォームがレンダリングされます。
input はレンダリングされず、ref は null のままです。
Effectが実行され、何もしません。
inputが表示され、ref は満たされますが、Effectは再実行されないので、フォーカスされません。
問題は、Form の render 関数にEffectが「束縛」されていることです。「Formがマウントされたとき」ではなく、「inputがレンダリングされたとき」にinputにフォーカスしたいのです。
Callback refs
ここで、コールバックrefが登場します。refの型宣言を見たことがある人は、refオブジェクトを渡すだけでなく、関数も渡すことができることがわかると思います。
type Ref<T> = RefCallback<T> | RefObject<T> | null
概念的には、React 要素の ref は、コンポーネントがレンダリングされた後に呼び出される関数であると考えましょう。この関数は、引数として渡されるレンダリングされたDOMノードを取得します。React要素がアンマウントされた場合は、nullでもう一度呼び出されます。
useRef(RefObject)からReact要素にrefを渡すのは、したがって、単なる以下のコードの糖衣構文になります
<input
ref={(node) => {
ref.current = node;
}}
defaultValue="Hello world"
/>
もう一度、強調しておきます。
全てのref propsはただの関数です
そして、これらの関数はレンダリング後に実行され、そこで副作用を実行するのは全く問題ありません。多分、refがonAfterRenderか何かという名前だったらもっと良かったのでしょう。
この知識があれば、nodeに直接アクセスできるcallback refの中でinputにフォーカスを簡単にあてることができます。
<input
ref={(node) => {
node?.focus()
}}
defaultValue="Hello world"
/>
まあ、細かいことを言えば、そうなんですけどね…Reactはレンダリングのたびにこの関数を実行します。そのため、頻繁にinputにフォーカスすることに抵抗があるのなら(そうでない可能性もありますが)、Reactにこの関数を実行したいときだけ実行するように指示しなければなりません。
const ref = React.useCallback((node) => {
node?.focus()
}, [])
return <input ref={ref} defaultValue="Hello world" />
これを初期バージョンと比較すると、コードが少なくなり、2つのhookの代わりに1つのhookを使うだけです。また、コールバックは DOM ノードをマウントするコンポーネントではなく、DOM ノードのライフサイクルにバインドされているため、あらゆる状況で動作します。さらに、strict mode(開発環境で実行する場合)では2回実行されることはなく、これは多くの人にとって重要だと思われます。
そして、(古い)Reactドキュメントのこの隠し玉に示されているように、あらゆる種類の副作用を実行するためにそれを使用することができます、例えば、その中でsetStateを呼び出します。この例は実際にかなり良いので、ここに置いておきます。
function MeasureExample() {
const [height, setHeight] = React.useState(0)
const measuredRef = React.useCallback(node => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height)
}
}, [])
return (
<>
<h1 ref={measuredRef}>Hello, world</h1>
<h2>The above header is {Math.round(height)}px tall</h2>
</>
)
}
レンダリング後にDOMノードに直接アクセスする必要がある場合は、useRef + useEffectではなく、代わりにcallback refを使うことを検討してください。