React Hook Formのwatchの基本的な仕組み

2024/06/20に公開

はじめに

今年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です。

registerwatchの変更検知

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が発火したフィールドのnamewatchの監視対象となっている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>
    </>
  )

上記のフォームの挙動を整理します。

  1. チェックボックスをクリック
  2. watchShowAge = watch("showAge", false)でフィールドの変更を検知。
  3. watchShowAge = trueに変化
  4. 再レンダリング。showAge=trueなので、<input type="number">が表示される

useFromの内部実装からwatchの仕組みを理解する

簡単にregisterwatchの説明をしてきましたが、ここからは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におけるフィールドの値を管理している実体です。フィールドにregisterrefnameを渡すことで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;
  }, []);

watchwatchingNamesRefに変更を検知する監視対象のフィールドのnameを格納したあと、そのフィールドの値をfieldsRefから取得し返すという処理をしています。

const register = useCallback(
    ...
    const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        if (watchingNamesRef.current.includes(e.target.name)) rerender();
        ...

最後にregisteronChangeです。今変更されているフィールドのnamewatchingNamesRefに登録されているかを調べ、登録されていれば再レンダリングするようになっています。

上記をまとめると、以下のようになります。

  • React Hook Formにおける各フィールドの値の管理の実体はfieldsRefで、registerを使ってfieldsRefに登録できる
  • 変更を検知する監視対象はfieldsRefとは別のwatchingNamesRefで管理しており、watchを使ってwatchingNamesRefに監視対象のnameを登録できる
  • あるフィールドでonChangeが発火したときに、そのnamewatchingNamesRefに登録されているかを調べ、登録されていれば再レンダリングする

※フィールドを管理しているfieldsRefwatchingNamesRefともにuseRefを使っているのは、レンダリング間でこれらのオブジェクトを共有したいからです。useRefは2回目以降のレンダーでは同じオブジェクトを返すので、再レンダリングが起こったとしても登録されているフィールドはリセットされません。

まとめ

React Hook Formの機能の一部であるwatchの内部の挙動をまとめてみました。React Hook Formは他にもたくさん機能がありますが、今回説明したような基本となる仕組みを理解しておくとsetValueなどの他の機能の仕組みも実装を見なくても類推できるのではないでしょうか。常に「内部はどうなっているのか?」を考えるようにしたいものです。今回は初の投稿ということもあり、過去記事の焼き直しのようになってしまいましたが、今後は自分で1からあのような勉強になる記事を書けるようになりたいですね。

Discussion