この資料は、Coral Capitalの主催するスタートアップ React LTに株式会社 kikitoriの CTO として登壇した際の内容を再編集したものです。
サンプルコード等は、以下のリポジトリで公開しています。
概要
React における Ref は、宣言的 UI を掲げる React の思想とは対極に位置する枠組みです。それ故に、Ref についての議論が交わされることは少ないと感じています。しかしながら、Hooks の登場により、Ref を用いる命令的なコードは、カスタムフックの中に隠蔽することが可能になりました。代表的な Ref の用途であるフォーカスの管理を例に、Ref を効果的に用いるライブラリの実装を参考にしながら、命令的な「汚い」コードを抽象化する手法について論じます。
そもそも Ref とは何か?
React の公式ドキュメントには、以下のように記載されています。
Ref は render メソッドで作成された DOM ノードもしくは React の要素にアクセスする方法を提供します。
Ref の基本的な使い方
useRef
は、Ref オブジェクトが返却します。このオブジェクトを ref
属性に渡すと、マウントされたタイミングで current
フィールドに DOM オブジェクトが格納されます。
export default function Sample1() {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<input ref={ref} />
<button
onClick={() => {
ref.current?.focus();
}}
>
focus
</button>
</>
);
}
Ref の歴史
React の Ref は 3 種類存在しています。
String Refs
React の初期リリースから存在している Ref です。すでに deprecated なので扱いません。
Callback Ref (v0.13.0 / 2015 年)
コンポーネントがマウントされる際にインスタンスオブジェクトがコールバックの引数に渡されます。String Refs より柔軟に Ref が扱えるようになりました。
Ref オブジェクト (v16.3.0 / 2018 年)
Callback Ref よりも開発者にとって使いやすいインターフェースを目指して開発されました。
Class Component 時代の Ref
Class Component 時代の Ref は、クラスコンポーネントのインスタンスを取得するための仕組みでした。
class Child extends Component {
render() {
return <div>Hello World</div>;
}
}
export default class Sample02 extends Component {
divRef = createRef<HTMLDivElement>();
divElement: HTMLDivElement | null = null;
childRef = createRef<Child>();
render() {
return (
<>
{/* Ref オブジェクトを使う場合 */}
<div ref={this.divRef} />
{/* Callback Ref を使う場合 */}
<div
ref={(element) => {
this.divElement = element;
}}
/>
<Child ref={this.childRef} />
</>
);
}
}
対象が DOM 要素であれば DOM オブジェクトが、カスタムコンポーネントであればそのインスタンスが取得できます。1 つ目の例は Ref オブジェクトを使用した例で、2 つ目の例は Callback Ref です。
例えば、子コンポーネントにパブリックメソッドが存在すれば、親コンポーネントから ref を通して呼び出すことができるようになります。
class Child extends Component<{}, { count: number }> {
state = { count: 1 };
increment() {
this.setState((state) => ({ count: state.count + 1 }));
}
render() {
return <div>{this.state.count}</div>;
}
}
export default class Sample03 extends Component {
ref = createRef<Child>();
render() {
return (
<>
<Child ref={this.ref} />
<button
onClick={() => {
this.ref.current?.increment();
}}
>
increment
</button>
</>
);
}
}
Hooks 以降の Ref
Hooks 以降の React では、コンポーネントは関数によって表現されるため、インスタンスという概念が存在しません。このため、関数コンポーネントでは Ref を利用できません。
function Child() {
return <div>Child</div>;
}
export default function Sample04() {
const ref = useRef<any>(null);
// @ts-expect-error Property 'ref' does not exist on type 'IntrinsicAttributes'.
return <Child ref={ref} />;
}
ただし、forwardRef
を使うと function component でも ref を公開できます。
const Child = forwardRef<HTMLInputElement>(function Child(_, ref) {
return <input ref={ref} />;
});
export default function Sample05() {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<Child ref={ref} />
<button
onClick={() => {
ref.current?.focus();
}}
>
focus
</button>
</>
);
}
ちなみに、ref
という属性名にこだわらなければ別に forwardRef
を使う必要はありません。forwardRef
が導入される以前は ref
以外の属性を利用して Ref を受け渡ししていました。
function Child(props: { innerRef: Ref<HTMLInputElement> }) {
return <input ref={props.innerRef} />;
}
export default function Sample06() {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<Child innerRef={ref} />
<button
onClick={() => {
ref.current?.click();
}}
/>
</>
);
}
しかしながら、例えば HOC を作る場合等、シグネチャが勝手に変わるのは困るという場合もあるでしょう。また、ライブラリ作成者が Class Component → Function Component に移行する場合、これまで ref
を使っていたコードとの互換性を維持するため、forwardRef
を使用すると良いでしょう。
また、useImperativeHandle
を用いることで自由な値を ref に設定できます。以下の例では、単純な関数を Ref として使用しています。
type ChildRef = () => void;
const Child = forwardRef<ChildRef>(function Child(_, ref) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => () => {
inputRef.current?.focus();
});
return <input ref={inputRef} />;
});
export default function Sample07() {
const ref = useRef<ChildRef>(null);
return (
<>
<Child ref={ref} />
<button
onClick={() => {
ref.current?.();
}}
>
focus
</button>
</>
);
}
useEffect を用いた useImperativeHandle の再実装
Ref を受け取ったコンポーネントは親コンポーネントにその Ref を通して自らを命令的に操作できるオブジェクトを伝えればよいので、useImperativeHandle
の機能は大体 useEffect
を使用してマウント時やアンマウント時に処理を行うことで代替できます。アンマウント時に null
を設定するのもお忘れなく。
useEffect(() => {
const refValue = () => {
inputRef.current?.focus();
};
// Callback Refの場合
if (typeof ref === "function") {
ref(refValue);
return () => {
ref(null);
};
}
// Ref オブジェクトの場合
if (ref) {
ref.current = refValue;
return () => {
ref.current = null;
};
}
});
Hooks 後の Ref のユースケース
Ref の用途:
- DOM にアクセスする
-
Class Component のインスタンス変数の代わり
- ミュータブル
- 変更しても再レンダリングされない
ここまでは Ref を DOM にアクセスするための仕組みとして扱ってきましたが、Hooks 以降の Ref は、むしろ Class Component のインスタンス変数の代わりとして扱われる場合のほうが多いです。
Ref の DOM 以外のユースケース
useMountedState
react-useのuseMountedStateは、コンポーネントが現在マウントされているかどうかを返す関数を取得します。この例では、Ref の参照が常に同一であることを利用し、コンポーネントがアンマウントされた後も変更・取得できる状態として Ref を作成しています。
export default function useMountedState(): () => boolean {
const mountedRef = useRef<boolean>(false);
const get = useCallback(() => mountedRef.current, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
return get;
}
useSelector
react-reduxのuseSelectorでは、selector
によって取得されたストアの一部分が変化したかどうかを判断するために Ref を利用しています。これにより、同一であると判断された場合に再レンダリングを抑制することができるようになります。
function useSelectorWithStoreAndSubscription(selector, equalityFn, store) {
const latestSelector = useRef();
const latestStoreState = useRef();
const latestSelectedState = useRef();
let selectedState;
if (
selector !== latestSelector.current ||
storeState !== latestStoreState.current
) {
const newSelectedState = selector(storeState);
if (!equalityFn(newSelectedState, latestSelectedState.current))
selectedState = newSelectedState;
else selectedState = latestSelectedState.current;
} else {
selectedState = latestSelectedState.current;
}
return selectedState;
}
業務システム開発にて
弊社 kikitori のプロダクトは、業務システム的な側面が強いです。業務システムでは、表形式の UI が採用される場合が多々あります。表形式の UI を作成する場合、フォーカスを適切に管理していく必要があります。
例えば、「Enter キーを押されたら次のセルに移動するようにして!」と言われたらどうしますか?
jQuery の時代だったら簡単に解決できた話ですが、時代は宣言的 UI です。React でフォーカスを操作するには Ref を使用せざるを得ないですが、どのように実装するのが良いでしょうか?
問題は、useRef の戻り値を DOM に渡すことで取得できるのは 1 要素までであるということです。しかしながら、大量の要素のフォーカス管理を行うためには、すべての要素を取得しておかなければなりません。当然のことながら、useRef
を何度も呼ぶという方法は Hook の制約に抵触するため、使用することができません。
React Hook Form
React Hook Formは、Ref を上手に活用することで、ハイパフォーマンスなフォームの実装を可能にしたライブラリです。
React のフォーム要素は、value
属性を指定すると制御(controlled)コンポーネントになります。制御コンポーネントでは、value 属性に指定した値が常にinput
に入力される値と同期されることが React によって保証されます。通常 React でフォーム要素を利用する場合は、フォームの値を State で管理します。この方法は通常うまく動作するのですが、フォームが入力される度に再レンダリングが発生するため、パフォーマンスが悪化します。
React Hook Form は、Ref を通してフォームの DOM 要素に直接アクセスするため、フォーム要素は非制御のままになります。
type FormValue = {
name: string;
};
export default function Sample09() {
const { register, handleSubmit } = useForm<FormValue>();
return (
<form
onSubmit={handleSubmit((e) => {
alert(e.name);
})}
>
<input {...register("name")} />
<input type="submit" />
</form>
);
}
React Hook Form の動作原理
React Hook Form のサンプルコードで最も印象的なのは明らかに register
関数です。この関数は、次のように動作します。
-
register
は{ onChange(), ref }
の形のオブジェクトを返す- 使う側はスプレッド構文で展開するだけで使える
-
ref
は Callback Ref - Callback Ref 内で連想配列の Ref オブジェクトに ref を保存する
function useForm() {
const ref = useRef<Record<string, HTMLInputElement>>({});
const register = (name: string) => ({
onChange() {},
ref(element: HTMLInputElement | null) {
ref.current[name] = element;
},
});
return { register };
}
フォーカス管理の Custom Hook
React Hook Form の仕組みは、Hooks を用いて複数の DOM 要素の管理する際の実装の指針となるものでしょう。「Enter キーが押されたら次のセルにフォーカスを移す」を実現するためには、各 input
要素に対し、キーイベントを取得するための onKeyDown
イベントハンドラと、要素のフォーカスを強制的に設定するための ref
を渡す必要があります。このため、作成するカスタムフックは、以下のような内容になるでしょう。
-
useRef
の中でMap<number, HTMLInputElement>
を管理する - Hook の戻り値は
register
関数で、フォーカスの順番を引数に指定すると{ onKeyDown(), ref }
を返す - 使う側はスプレッド構文で展開するだけ
function useEnterKeyFocusControl() {
const ref = useRef(new Map<number, HTMLInputElement>());
return (index: number) => ({
onKeyDown({ key }: { key: string }) {
if (key !== "Enter") return;
const sortedIndices = [...ref.current.keys()].sort();
const nextIndex = sortedIndices[sortedIndices.indexOf(index) + 1];
if (typeof nextIndex === "number") ref.current.get(nextIndex)?.focus();
},
ref(element: HTMLInputElement | null) {
if (element) ref.current.set(index, element);
else ref.current.delete(index);
},
});
}
ポイントは、ref
関数の実装です。useImperativeHandle
の例で見たように、Callback Ref は、要素がマウントされた際にその要素が、アンマウントされた際に null
が引数にセットされて呼ばれます。要素数が動的に変化するような UI にも対応できるよう、上記の例では、null
が渡ってきた(=要素がアンマウントされた)際に Map から要素を削除しています。
この Hook を使用すると、「Enter キーが押されたら次のセルにフォーカスが移る」は、次のように実装できます。フォーカスを管理するという処理が、コンポーネント側から見ると宣言的な API で記述できるようになっています。
export default function Sample10() {
const register = useEnterKeyFocusControl();
return (
<>
{[...Array(10).keys()].map((i) => (
<input key={i} {...register(i)} />
))}
</>
);
}
まとめ
- Hooks 時代の Ref の用途は 2 つ
- DOM へのアクセス
- ミュータブルな値を、レンダリングから独立して管理する
- useRef で得られるのは Ref オブジェクトだが、Callback Ref を組み合わせて使うとより柔軟に制御できる
- 「Callback Ref を生成する関数」を返す Custom Hook を作ると複数要素の Ref を高い抽象度で扱える
We're Hiring!
弊社 kikitoriは、日本の農業流通の 8 割を占める市場流通の DX を目指す SaaS、nimaru の開発に携わってくださる方を募集しています。詳細は弊社ウェブサイトをご覧ください。