2023年8月30日
ReactにおけるRef:DOMへのアクセスから命令的APIまで
本記事は、原著者の許諾のもとに翻訳・掲載しております。
この記事では、ReactにおいてDOMへのアクセスが必要な理由と、その際にRefがどう役立つのかを見ていきます。また、useRef、forwardRef、useImperativeHandleという3つのフックについて説明し、これらを適切に使用する方法を紹介したいと思います。 この記事と同じ内容を扱ったYouTube動画も公開していますので、活字媒体よりも動画視聴を好まれる方はそちらをご覧ください。文字ではなく、アニメーションと音声で同じ概念を解説しています。 この記事は動画形式でも公開しています。
目次
- useRefを使用してReactでDOMにアクセスする
- 親から子にRefをpropとして渡す
- forwardRefを使用して親から子にRefを渡す
- useImperativeHandleを使用した命令型API
- useImperativeHandleを使用しない命令型API
Reactには素晴らしい点が多数ありますが、その1つは実際のDOMを抽象化することでその複雑性を取り除いてくれることです。手動でクエリを実行して要素を取得したり、取得した要素にどのようにしてクラスを追加するか頭を悩ませたり、ブラウザの不整合に手を焼いたりすることがなく、コンポーネントを記述するだけでよいので、ユーザーエクスペリエンスにフォーカスすることができます。しかし、極めてまれではありますが、実際のDOMにアクセスしなくてはならないケースもあります。
実際のDOMに関しては、RefとRefに関連するあらゆることを理解し、その適切な使用方法を学ぶことが何よりも重要です。したがって、今日はそもそもなぜDOMにアクセスする必要があるのか、その際にRefがどう役立つのかを説明し、useRef
、forwardRef
、useImperativeHandle
の特徴とその適切な使用方法について見ていきたいと思います。また、forwardRef
とuseImperativeHandle
の使用を避けつつ、その恩恵を受ける方法についても詳しく話します。これらのフックの仕組みについて調べてみたことのある人なら、なぜそうすることが望ましいのかお分かりいただけるでしょう。
さらに、Reactで命令的APIを実装する方法についても紹介します。
useRefを使用してReactでDOMにアクセスする
自分が主催する会議の登録フォームを実装するとします。参加者に詳細を送れるように、氏名とメールアドレス、Twitter(現在は「X」に名称変更)ハンドルネームを知らせてもらう必要があり、「氏名」と「メールアドレス」の入力欄は必須項目にしたいと思っています。ただし、空欄のまま送信ボタンが押されても、ただ赤枠を表示するだけでは面白くないので、空欄にフォーカスし、入力欄が揺れるようにすることで入力を促します。 Reactには多くの機能が備わっていますが、必要なものが全てそろっているわけではありません。例えば、「手動で要素にフォーカスする」といった機能は提供されていません。したがって、この機能を実装するにはJavaScriptのネイティブAPIを利用しなくてはならず、そのためには実際のDOM要素にアクセスする必要があります。 Reactが存在しない世界では、まず次のような処理を実行します。
const element = document.getElementById("bla");
次に、要素にフォーカスします。
element.focus();
もしくは、その要素までスクロールします。
element.scrollIntoView();
他にもやり方はあるでしょう。Reactの世界でネイティブのDOM APIを使用する場合、以下のようなユースケースが一般的です。
- フォームの入力欄のように、要素をレンダリングした後、手動で要素にフォーカスする
- ポップアップのような要素を表示する際、コンポーネントの外のクリックを検知する
- 画面上に要素が表示された後、手動で要素までスクロールする
- ツールチップのようなものを正しい位置に表示するために、コンポーネントのサイズと境界を計算する
厳密には、今でもgetElementById
メソッドを使おうと思えば使えますが、Reactではより強力な方法で指定要素にアクセスすることができます。その方法を使えば、至るところにIDを設定する必要がなく、DOMの基本構造を意識する必要もありません。その方法というのがRefです。
Refは単なるミュータブルなオブジェクトであり、Refへの参照は再レンダリングをまたいでReactが保持します。Refは再レンダリングをトリガーしないため、ステートの代わりにはなりません。したがって、ステートをRefで代用しようとするのは避けてください。両者の違いに関する詳細は、ドキュメントをご覧ください。
Refは、useRef
フックを使用して作成します。
const Component = () => {
// create a ref with default value null
const ref = useRef(null);
return ...
}
Refに格納された値は、currentという(唯一の)プロパティの中にあります。この中には何でも格納できます。例えば、ステートから取得した値をオブジェクトに格納することもできます。
const Component = () => {
const ref = useRef(null);
useEffect(() => {
// re-write ref's default value with new object
ref.current = {
someFunc: () => {...},
someValue: stateValue,
}
}, [stateValue])
return ...
}
あるいは、ここでお話しするユースケースにとってより重要な例を挙げると、Refは任意のDOM要素と一部のReactコンポーネントに代入できます。
const Component = () => {
const ref = useRef(null);
// assign ref to an input element
return <input ref={ref} />
}
useEffect
(コンポーネントがレンダリングされた後のみ利用可能)の中でref.current
をログ出力すると、その入力に対してgetElementById
を実行した場合と全く同じ要素が得られます。
const Component = () => {
const ref = useRef(null);
useEffect(() => {
// this will be a reference to input DOM element!
// exactly the same as if I did getElementById for it
console.log(ref.current);
});
return <input ref={ref} />
}
そして、登録フォームを1つの巨大なコンポーネントとして実装するならば、次のような処理を実行できます。
const Form = () => {
const [name, setName] = useState('');
const inputRef = useRef(null);
const onSubmitClick = () => {
if (!name) {
// focus the input field if someone tries to submit empty name
ref.current.focus();
} else {
// submit the data here!
}
}
return <>
...
<input onChange={(e) => setName(e.target.value)} ref={ref} />
<button onClick={onSubmitClick}>Submit the form!</button>
</>
}
入力された値をステートに保存し、全ての入力に対してRefを作成し、「送信」ボタンがクリックされたら値が空ではないか確認し、空であれば必要な入力欄にフォーカスします。 このフォームの実装をCodeSandboxで公開していますのでご覧ください。
親から子にRefをpropとして渡す
もちろん、実際に全てを1つの巨大なコンポーネントとして実装することはありません。どちらかといえば、入力は複数のフォームで再利用し、独自のスタイルをカプセル化して制御できるよう、固有のコンポーネントに抽出したいと思います。そうすることで、上部にラベルを表示したり、右側にアイコンを表示したりといった機能を追加することもできます。
const InputField = ({ onChange, label }) => {
return <>
{label}<br />
<input type="text" onChange={(e) => onChange(e.target.value)} />
</>
}
しかし、エラーハンドリングと送信機能はinputではなく、依然としてFormの中にあります。
const Form = () => {
const [name, setName] = useState('');
const onSubmitClick = () => {
if (!name) {
// deal with empty name
} else {
// submit the data here!
}
}
return <>
...
<InputField label="name" onChange={setName} />
<button onClick={onSubmitClick}>Submit the form!</button>
</>
}
入力に対し、Form
のコンポーネントから「自らにフォーカスする」ように指示するにはどうすればよいでしょうか。通常、Reactにおいてデータと挙動を制御するには、コンポーネントにpropを渡し、コールバックを監視します。「focusItself」というpropをInputField
に渡し、これをfalse
からtrue
に切り替えることもできますが、これがうまくいくのは1回だけです。
// don't do this! just to demonstate how it could work in theory
const InputField = ({ onChange, focusItself }) => {
const inputRef = useRef(null);
useEffect(() => {
if (focusItself) {
// focus input if the focusItself prop changes
// will work only once, when false changes to true
ref.current.focus();
}
}, [focusItself])
// the rest is the same here
}
「onBlur」というコールバックを追加して、入力がフォーカスを失ったらfocusItself
propをリセットしてfalse
に戻したり、ブール値の代わりに乱数値に変更したりすることもできます。あるいは、その他の工夫を考案することもできるかもしれません。
おそらく、他にもやり方はあるでしょう。propをいじるのではなく、単にあるコンポーネント(Form
)の中にRefを作成し、これを別のコンポーネント(InputField
)に渡して、そこで基本構造のDOM要素に接続することもできるでしょう。結局のところ、Refはただのミュータブルなオブジェクトなので。
次に、Form
が今までと同じくRefを作成します。
const Form = () => {
// create the Ref in Form component
const inputRef = useRef(null);
...
}
InputField
コンポーネントには、今までと同じくRefを受け取るためのpropとinput
フィールドがあります。違うのは、RefがInputField
の中で作成されるのではなく、propから渡されるということです。
const InputField = ({ inputRef }) => {
// the rest of the code is the same
// pass ref from prop to the internal input component
return <input ref={inputRef} ... />
}
Refはミュータブルなオブジェクトであり、そのように設計されています。Refを要素に渡すと、その下にあるReactがRefに変更を加えます。そして、変更されるオブジェクトはForm
コンポーネントの中で宣言されます。したがって、InputField
がレンダリングされると、Refオブジェクトはすぐに変更され、Form
はinputRef.current
内のinput
DOM要素にアクセスすることができます。
const Form = () => {
// create the Ref in Form component
const inputRef = useRef(null);
useEffect(() => {
// the "input" element, that is rendered inside InputField, will be here
console.log(inputRef.current);
}, []);
return (
<>
{/* Pass ref as prop to the input field component */}
<InputField inputRef={inputRef} />
</>
)
}
あるいは、submitコールバックの中で、前と全く同じコードであるinputRef.current.focus()を呼び出すことができます。 こちらの例をご覧ください。
forwardRefを使用して親から子にRefを渡す
propの名前を単にref
ではなく、inputRef
としたのはなぜだろうと不思議に思う人もいるかもしれませんが、実際はそれほど単純なことではありません。ref
は実際にはpropではなく、予約語のようなものです。昔、まだクラスコンポーネントを記述していた頃は、Refをクラスコンポーネントに渡すと、このコンポーネントのインスタンスはそのRefの.current
値になっていました。
しかし、関数コンポーネントにインスタンスはないため、コンソール上に次のような警告が表示されます。「Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?」
const Form = () => {
const inputRef = useRef(null);
// if we just do this, we'll get a warning in console
return <InputField ref={inputRef} />
}
これが機能するためには、Reactにこのref
が実際に意図したものであり、これを使ってやりたいことがあると伝える必要があります。それには、forwardRef
関数の助けを借ります。この関数は、コンポーネントを受け入れ、コンポーネントの関数の第2引数としてref
属性からRefを挿入します。挿入位置はpropのすぐ後ろです。
// normally, we'd have only props there
// but we wrapped the component's function with forwardRef
// which injects the second argument - ref
// if it's passed to this component by its consumer
const InputField = forwardRef((props, ref) => {
// the rest of the code is the same
return <input ref={ref} />
})
上のコードを2つの変数に分けることで、可読性を向上させることもできます。
const InputFieldWithRef = (props, ref) => {
// the rest is the same
}
// this one will be used by the form
export const InputField = forwardRef(InputFieldWithRef);
これで、Form
は通常のDOM要素と同様にRefをInputFieldコンポーネントに渡すことができます。
return <InputField ref={inputRef} />
forwardRef
を使用するか、Refをpropとして渡すかは、単なる好みの問題であり、最終結果は同じです。
こちらの実例をご覧ください。
useImperativeHandleを使用した命令的API
Form
コンポーネントから入力欄にフォーカスする方法については大体お分かりいただけたかと思いますが、目指しているフォームはまだ完成ではありません。エラーが発生した場合、フォーカスするだけでなく入力欄が揺れるようにしたいとお話ししたと思います。JavaScriptのネイティブAPIにelement.shake()
のようなものはありませんので、ここではDOM要素にアクセスしても役に立ちません。
しかし、CSSアニメーションとして簡単にこの機能を実装できます。
const InputField = () => {
// store whether we should shake or not in state
const [shouldShake, setShouldShake] = useState(false);
// just add the classname when it's time to shake it - css will handle it
const className = shouldShake ? "shake-animation" : '';
// when animation is done - transition state back to false, so we can start again if needed
return <input className={className} onAnimationEnd={() => setShouldShake(false)} />
}
では、アニメーションをトリガーするにはどうすればよいでしょうか。この場合も、フォーカスのときと要領は同じです。propを使って何かしら工夫を考案することはできるかもしれませんが、見た目が不自然であるだけでなく、かなり複雑なフォームになってしまいます。特に、Refを使用してフォーカスを実現していることを考えると、全く同じ問題に対して2つのソリューションを用いることになります。InputField.shake()
とInputField.focus()
のようなことができたら良いのですが。
フォーカスについて言うと、これをトリガーするためにForm
コンポーネントがいまだにネイティブのDOM APIに頼る必要があるのはなぜでしょうか。こうした複雑性を抽象化によって取り除くのが、InputField
のそもそもの目的であり、担うべき役割ではないのでしょうか。フォームが基本構造のDOM要素にアクセスできるのもおかしな話です。これによって、実装に関する内部の詳細が漏れてしまいます。どのDOM要素を使用しているのか、そもそもDOM要素を使用しているのか、あるいは別のものを使用しているのかは、Form
コンポーネントの関心事であるべきではないのです。関心の分離の原則があるので。
InputField
コンポーネントに適切な命令的APIを実装するべきときが来たようです。現状、Reactは宣言的であり、それに従ってコードを記述することが求められています。しかし、命令によって何かをトリガーする方法が必要なこともあります。Reactには、そのようなときのためのescape hatchが用意されています。それがuseImperativeHandleフックです。
このフックは理解するのが少し難しく、筆者もドキュメントを2回読み、何度か自分で試し、実際のReactコードに実装してみてようやくその仕組みを理解できました。しかし必要なものは、基本的に命令的APIの形を決めることと、それを接続するRefの2つだけです。入力はシンプルです。.focus()
と.shake()
の関数がAPIとして必要なだけであり、Refについてはすでに分かっています。
// this is how our API could look like
const InputFieldAPI = {
focus: () => {
// do the focus here magic
},
shake: () => {
// trigger shake here
}
}
このuseImperativeHandle
フックは、このオブジェクトをRefオブジェクトのcurrentプロパティに接続するだけです。その方法は以下の通りです。
const InputField = () => {
useImperativeHandle(someRef, () => ({
focus: () => {},
shake: () => {},
}), [])
}
第1引数はRefであり、コンポーネント自体の中で作成されるか、propから、もしくはforwardRefを通して渡されます。第2引数はオブジェクトを返す関数であり、このオブジェクトはinputRef.current
として利用できます。第3引数は依存配列であり、他のReactフックと同様です。
ここでのコンポーネントには、RefをapiRef
propとして明示的に渡します。後は、実際のAPIを実装するだけです。そのためには別のRefが必要になります。こちらはInputField
内のRefであり、通常通りinput
DOM要素に接続してフォーカスをトリガーできます。
// pass the Ref that we'll use as our imperative API as a prop
const InputField = ({ apiRef }) => {
// create another ref - internal to Input component
const inputRef = useRef(null);
// "merge" our API into the apiRef
// the returned object will be available for use as apiRef.current
useImperativeHandle(apiRef, () => ({
focus: () => {
// just trigger focus on internal ref that is attached to the DOM object
inputRef.current.focus()
},
shake: () => {},
}), [])
return <input ref={inputRef} />
}
「揺れる」動きについては、ステートの更新をトリガーします。
// pass the Ref that we'll use as our imperative API as a prop
const InputField = ({ apiRef }) => {
// remember our state for shaking?
const [shouldShake, setShouldShake] = useState(false);
useImperativeHandle(apiRef, () => ({
focus: () => {},
shake: () => {
// trigger state update here
setShouldShake(true);
},
}), [])
return ...
}
これで、Formはrefを作成し、InputFieldに渡し、内部の実装について心配することなく、簡単なinputRef.current.focus()とinputRef.current.shake()を実行できます。
const Form = () => {
const inputRef = useRef(null);
const [name, setName] = useState('');
const onSubmitClick = () => {
if (!name) {
// focus the input if the name is empty
inputRef.current.focus();
// and shake it off!
inputRef.current.shake();
} else {
// submit the data here!
}
}
return <>
...
<InputField label="name" onChange={setName} apiRef={inputRef} />
<button onClick={onSubmitClick}>Submit the form!</button>
</>
}
完全に機能するフォームのサンプルを実際に使ってみてください。
useImperativeHandleを使用しない命令的API
useImperativeHandle
フックは目がけいれんを起こしそうだという人もいるかもしれません。実は、私もその1人です。今お話しした機能は、このフックを実際に使わなくても実装できます。Refの仕組みと、Refが変更可能であることはすでに分かっています。したがって、必要なRefのref.current
にAPIオブジェクトを代入するだけでいいのです。以下に例を挙げます。
const InputField = ({ apiRef }) => {
useEffect(() => {
apiRef.current = {
focus: () => {},
shake: () => {},
}
}, [apiRef])
}
これは、useImperativeHandle
がバックグラウンドで実行する処理とほぼ同じです。実際の動作は以前と全く同じです。
実際には、useLayoutEffect
の方がもっと良いかもしれませんが、これについてはまた別の記事でお話ししたいと思います。今回は、従来通りuseEffect
を使用します。
最後の例をご覧ください。
揺れる動作が実装されたすてきなフォームの完成です。ReactにおけるRefの謎は解明され、命令的APIも使えるようになりました。実に素晴らしい。 次の点を覚えておいてください。Refはいわば「escape hatch」のようなもので、propやコールバックを使用したReactの通常のデータフローやステートの代わりになるものではありません。「通常の」代替策がない場合のみ使用してください。命令的に何かをトリガーするのも同様です。どちらかといえば、propやコールバックを使用した通常のフローの方が望ましいので。 さらに理解を深めていただくために、YouTube動画もぜひご覧ください。概念によっては、2段落分の文章よりも3秒のアニメーションの方が伝わりやすい場合もあります。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa