React Hook Formのwatchの基本的な仕組み
はじめに
今年4月に新卒入社し、webアプリのフロントエンドの開発をしています。最近React Hook Formを初めて使ったのですが、「どうやってフォームの値を管理・監視しているんだ…?」と疑問に思い調べてみたところ、とても良い記事を見つけました。
先の記事ではオブザーバパターンなどReact Hook Formの基底となる概念から解説していますが、ここではそこまで立ち入らずにwatch
の基本的な仕組みを実装から整理していきます。
React Hook Formの誕生背景
React Hook Formの概要については参考記事に書いてあるので、ここではさらっと説明します。
新しくライブラリを学ぶ上で大切なのは「そのライブラリがどのような課題を解決したのか」、つまりライブラリ誕生のコンテキストを理解することです。React Hook Formだと、「フォーム画面の再レンダリング数を抑えて、パフォーマンスを向上したい」というのが解決したい課題です。
フォーム画面では、入力内容に応じてバリデーションチェックとエラーメッセージの表示をするといった、インタラクティブな処理を行いたいという要求が出てくることが多いと思います。動的に変化するフィールドの入力値を管理する方法の1つとしてstateが挙げられます。しかしstateでフィールドの値を管理しようとすると、フィールドが入力される度にstateが変化し再レンダリングが走ることになるのでパフォーマンスが悪くなります。
では、値が変化しても再レンダリングが走らないようにするにはどうすればよいでしょうか?そこで出てくるのがref
です。Reactの公式ドキュメントにもありますが、ref
は値が変更されても再レンダリングが走りません。このことから、「ref
を使ってフィールドの値を管理すれば、再レンダリング数を抑えた高パフォーマンスなフォームを実現できるのでは?」という考えが生まれます。これを実現したのがReact Hook Formです。
register
とwatch
の変更検知
React Hook Formの詳しい使い方は公式ドキュメント見てねということで、本題であるwatch
の仕組みを説明していきますが、仕組みを理解するためにはwatch
と同じくuseForm()
の返り値であるregister
を知っておく必要があります。
register
関数はその名の通り返り値(name, ref, onChange, onBlur
)をフィールドに渡すことで、そのフィールドをReact Hook Formの内部に管理対象として登録します。register
関数の返り値のname
は引数に渡すname
と同一です。
watch
は監視対象のフィールドの値の変更を検知し、その変更後の値を返す関数です。監視対象のフィールドのname
を第1引数に、デフォルト値を第2引数に渡します。
const { register, watch } = useForm()
const watchShowAge = watch("name")
...
return (
...
<input {...register("name")}/>
...
)
register
で管理対象として登録されたフィールドでChangeEvent
が発火すると、register
の返り値のonChange
が呼び出されます。このonChange
ハンドラの中で、ChangeEvent
が発火したフィールドのname
がwatch
の監視対象となっているname
と一致しているかを確認し、一致していれば再レンダリングを実行します。
再レンダリングはuseForm
を呼び出しているコンポーネント全体に実行されます。これにより、ユーザーの操作結果に応じてレンダリングする内容を変更することができます。
実装例を見てみましょう。以下の例では、チェックボックスがtrue
のときだけname="age"
のinput
を表示するという実装をしています。
const {
register,
watch,
formState: { errors },
handleSubmit,
} = useForm()
// name="showAge"のフィールドの変更を検知し、変更後の値を変数に格納
const watchShowAge = watch("showAge", false)
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
{/* watchの監視対象のフィールド。これが変更されると全体が再レンダリング */}
<input type="checkbox" {...register("showAge")} />
{watchShowAge && (
<input type="number" {...register("age", { min: 50 })} />
)}
<input type="submit" />
</form>
</>
)
上記のフォームの挙動を整理します。
- チェックボックスをクリック
-
watchShowAge = watch("showAge", false)
でフィールドの変更を検知。 -
watchShowAge = true
に変化 - 再レンダリング。
showAge=true
なので、<input type="number">
が表示される
watch
の仕組みを理解する
useFromの内部実装から簡単にregister
とwatch
の説明をしてきましたが、ここからはuseForm
の簡易的な内部実装コードからwatch
の仕組みを説明していきたいと思います。コードは冒頭で紹介した参考記事でも引用されている、React Hook Formのメンバーでもある@kotarella1110さんのものを拝借しております。ここでは概念的な説明をするので、細かい処理の解説は引用元の記事をご参照ください。
早速ですが、以下がuseForm
の実装です。
...
export function useForm<
TFieldValues extends Record<string, any>
>(): UseFormReturn<TFieldValues> {
const rerender = useReducer(() => ({}), {})[1];
const fieldsRef = useRef<Fields<TFieldValues>>({} as Fields<TFieldValues>);
const watchingNamesRef = useRef<(keyof TFieldValues)[]>([]);
const subjectRef = useRef(createSubject<{ name: string }>());
const register = useCallback(
(name: keyof TFieldValues) => {
const ref = <
TElement extends
| HTMLInputElement
| HTMLSelectElement
| HTMLTextAreaElement
>(
node: TElement | null
) => {
if (node) fieldsRef.current[name] = node;
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (watchingNamesRef.current.includes(e.target.name)) rerender();
subjectRef.current.next({ name: e.target.name });
};
return {
name,
ref,
onChange
} as ReturnType<UseFormReturn<TFieldValues>["register"]>;
},
[rerender]
);
const watch = useCallback((name: keyof TFieldValues) => {
watchingNamesRef.current.push(name);
return fieldsRef.current[name]?.value;
}, []);
return {
register,
watch,
control: useMemo(
() => ({
fieldsRef,
subjectRef
}),
[]
)
};
}
const rerender = useReducer(() => ({}), {})[1];
はuseForm
を呼び出しているコンポーネントを再レンダリングする関数です。
const fieldsRef = useRef<Fields<TFieldValues>>({} as Fields<TFieldValues>);
fieldsRef
はReact Hook Formにおけるフィールドの値を管理している実体です。フィールドにregister
のref
とname
を渡すことでfieldsRef
に登録されます。登録されている各フィールドの値は、name
をkeyにしたオブジェクトで管理されています。
const watchingNamesRef = useRef<(keyof TFieldValues)[]>([]);
watchingNamesRef
には変更を検知するフィールドのname
が格納されています。watch("name")
を実行することでここに登録されます。
const watch = useCallback((name: keyof TFieldValues) => {
watchingNamesRef.current.push(name);
return fieldsRef.current[name]?.value;
}, []);
watch
はwatchingNamesRef
に変更を検知する監視対象のフィールドのname
を格納したあと、そのフィールドの値をfieldsRef
から取得し返すという処理をしています。
const register = useCallback(
...
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (watchingNamesRef.current.includes(e.target.name)) rerender();
...
最後にregister
のonChange
です。今変更されているフィールドのname
がwatchingNamesRef
に登録されているかを調べ、登録されていれば再レンダリングするようになっています。
上記をまとめると、以下のようになります。
- React Hook Formにおける各フィールドの値の管理の実体は
fieldsRef
で、register
を使ってfieldsRef
に登録できる - 変更を検知する監視対象は
fieldsRef
とは別のwatchingNamesRef
で管理しており、watch
を使ってwatchingNamesRef
に監視対象のname
を登録できる
- あるフィールドで
onChange
が発火したときに、そのname
がwatchingNamesRef
に登録されているかを調べ、登録されていれば再レンダリングする
※フィールドを管理しているfieldsRef
、watchingNamesRef
ともにuseRef
を使っているのは、レンダリング間でこれらのオブジェクトを共有したいからです。useRef
は2回目以降のレンダーでは同じオブジェクトを返すので、再レンダリングが起こったとしても登録されているフィールドはリセットされません。
まとめ
React Hook Formの機能の一部であるwatch
の内部の挙動をまとめてみました。React Hook Formは他にもたくさん機能がありますが、今回説明したような基本となる仕組みを理解しておくとsetValue
などの他の機能の仕組みも実装を見なくても類推できるのではないでしょうか。常に「内部はどうなっているのか?」を考えるようにしたいものです。今回は初の投稿ということもあり、過去記事の焼き直しのようになってしまいましたが、今後は自分で1からあのような勉強になる記事を書けるようになりたいですね。
Discussion