Ref を経由して特定の DOM ノードにアクセスする
通常ステートレスな React コンポーネントは、props によって何がレンダリングされるか決まります (宣言的 UI)。しかし、コンポーネント内の DOM ノードにアクセスして focus()
を呼び出したいなど、親コンポーネントから子の DOM ノードを操作したいケースがあります。
React では親コンポーネントで定義した ref を子コンポーネントに forwarding することで、特定の DOM ノードにアクセスします。
import { forwardRef, useRef, useState } from 'react';
// 子コンポーネント
const Textbox = forwardRef<
// ^^^^ 受け取った ref を forwarding する
HTMLInputElement,
{ text: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void }
>(function Child({ text, onChange }, ref) {
return <input type="text" onChange={onChange} value={text} ref={ref} />;
// ^^^^ 親コンポーネントからアクセスしたい DOM ノードに ref を代入する
});
// 親子コンポーネント
const InputForm = () => {
const initialText = '';
const [text, setText] = useState(initialText);
// ref を宣言する
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.currentTarget.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
alert(text);
setText(initialText);
// current プロパティで DOM ノードを参照できる
inputRef.current?.focus();
};
return (
<form onSubmit={handleSubmit}>
<Textbox text={text} onChange={handleChange} ref={inputRef} />
{/* ^^^^ 子コンポーネントに ref を渡す */}
<button type="submit">Submit</button>
</form>
);
};
export default InputForm;
やりたかったこと
dialog 要素は showModal
メソッドで自身をモダールとして表示したり、close
メソッドで閉じたりできます。
Members というコンポーネントを作るにあたり、モーダルの状態をその中で管理する必要がありました。これらのメソッドをイベントハンドラから呼び出すには、最下層に位置する Modal コンポーネントの dialog 要素まで ref を forwarding しなければなりません。
はじめは forwardRef で ref をバケツリレーするつもりでしたが、以下のような課題が浮上しました。
- 親コンポーネントに ref を渡すことを強制したい
- ref を中継するコンポーネントでイベントハンドラを宣言したい
- Container / Presentational Pattern を使うため 2 つの ref を渡したい
これらの課題を解決する方法を調べたところ、ref を props として渡す方法を見つけました。順を追って見ていきましょう。
親コンポーネントに ref を渡すことを強制したい
forwardRef の型定義は以下のようになっています。
declare namespace React {
// ...
function forwardRef<T, P = {}>(render: ForwardRefRenderFunction<T, P>): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
interface RefAttributes<T> extends Attributes {
ref?: Ref<T> | undefined;
}
}
返り値の一部となっている RefAttributes の ref プロパティは省略可能かつ undefined を許容しています。Modal コンポーネントは ref の受け渡しが不可欠であることを考えると、このインターフェースは都合が悪いと感じました。
一方で Modal コンポーネントの ref を props として受け取ると次のようになります。
import type { ComponentPropsWithRef } from 'react';
type Props = ComponentPropsWithRef<'dialog'> & {
dialogRef: React.RefObject<HTMLDialogElement>;
children: React.ReactNode;
};
const Modal = ({ dialogRef, children }: Props) => (
<dialog ref={dialogRef}>{children}</dialog>
);
export default Modal;
ref を必要とすることが明示的になり、ref を渡し損ねると TypeScript がエラーを出してくれます。
ref を中継するコンポーネントでイベントハンドラを宣言したい
Members コンポーネントにロジックが集中しているため、少しでもコード量を減らしたいと考えました。そこで ref を中継するコンポーンネント (FilterModal や SortModal) にイベントハンドラを切り分けるというアイデア (コンポーネント間の依存関係が高まるため、よい実装なのかはわかりません🤷♂️) を思いついたのですが、ここでも forwardRef の型が問題となります。
Ref<T>
は RefCallback<T>
と RefObject<T>
という 2 つ側面を持つため、DOM ノードの参照に使う RefObject として TypeScript に推論させることは困難です。
declare namespace React {
// ...
interface RefAttributes<T> extends Attributes {
ref?: Ref<T> | undefined;
}
type Ref<T> = RefCallback<T> | RefObject<T> | null;
}
そこで FilterModal コンポーネントも ref を props で受け取るように変更してみます。props の型として RefObject<HTMLDialogElement>
まで絞り込めるため、current プロパティにアクセスできます。
import Modal from '@/components/Modal';
import type { ComponentProps } from 'react';
// 上記の Modal コンポーネントと同じ型の Props を用いる
type Props = ComponentProps<typeof Modal>;
const FilterModal = ({ dialogRef, children }: Props) => {
const handleClose = () => {
dialogRef.current?.close();
};
return (
<Modal dialogRef={dialogRef}>
{children}
<button type="button" onClick={handleClose} />
</Modal>
);
};
export default FilterModal;
Container / Presentational Pattern を使うため 2 つの ref を渡したい
複数の ref を取り扱う方法を調べたところ、React 公式が提示している情報は見つけられませんでした。どうやら useImperativeHandler
というフックを使って、forwarding した ref をカスタマイズする方法があるようです。
props であれば必要な分だけプロパティを増やすだけで済みます。
type Props = {
filterModalRef: RefObject<HTMLDialogElement>;
sortModalRef: RefObject<HTMLDialogElement>;
};
おわりに
現在 React の公式ドキュメントには ref を props で渡す方法は記載されていません。この方法を採用する場合は自己責任となりますが、forwardRef ではカバーしにくいエッジケースで役に立つと感じました。