📨

React Hook Formは非制御コンポーネントからどのように変更を検知しているのか?

2023/08/12に公開
3

はじめに

React Hook Form が 「非制御コンポーネント」 としてフォームを扱うことでパフォーマンスなどを最適化しているのは有名ですが、 watchuseWatch で値が入力されるたびにどのように変更を検知しているのか?」 などについては意外と知っている人が少なそうだったのでまとめてみました。

どなたかの参考になれば幸いです。ぜひ最後までご覧ください。

「非制御コンポーネント」とはなにか?

本題に入る前に、まず React Hook Form を語る上では欠かせない 「制御コンポーネント」「非制御コンポーネント」 について軽く触れておきます。

制御コンポーネント

まず「制御コンポーネント」とは一言で言うなら 「入力要素の状態を React(state)が保持するコンポーネント」 のことです。

https://ja.legacy.reactjs.org/docs/forms.html#controlled-components

メリットとしては常に値にアクセスできるため、「ユーザが入力中にバリデーションを実施する」といったリアクティブなフォームを作成できます。

デメリットとしては state で管理するため、入力値が更新されるたびに 再レンダリングが発生してしまうといったことが挙げられます。(他にもコンポーネントが肥大化しがちなどがある...)

具体的な実装例としては以下のようになります。

const ControlledForm = () => {
  // 入力値を保持するstateを用意
  const [inputValue, setInputValue] = useState < string > "";

  // 入力値の値が変わるたびに実行する
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);
  };

  // submit時に実行する
  const handleSubmitClick = () => {
    // 10文字以内ならログに出す。
    if (inputValue.length <= 10) {
      console.log(inputValue);
    }
  };

  const errorMessage =
    inputValue.length > 10 ? "10文字以内で入力してください。" : null;
  return (
    <>
      // フォームを構築
      <input type="text" value={inputValue} onChange={handleChange} />
      // エラー文の表示
      {errorMessage && <p>{errorMessage}</p>}
      <button onClick={handleSubmitClick}>確定</button>
    </>
  );
};

非制御コンポーネント

一方で「非制御コンポーネント」とは 「入力要素の状態を DOM 自身が保持するコンポーネント」 のことです。

https://ja.legacy.reactjs.org/docs/uncontrolled-components.html

メリットとしては state を経由しないため、入力値の更新毎に 再レンダリングが発生しません。 このため、パフォーマンス面では制御コンポーネントよりも優れています。

デメリットとしては必要なタイミング(submit 時など)で DOM から入力値を取得するため、 「入力中にバリデーションを実施する」 といったリアルタイム性が必要な実装が難しいことが挙げられます。

具体的な実装例としては以下のようになります。基本的にはuseRefref属性を利用していきます。

const UncontrolledForm = () => {
  // input要素を監視するためのオブジェクトを用意する
  const ref = useRef<HTMLInputElement>(null);

  // submit時に実行する
  const handleSubmitClick = () => {
    const value = ref.current?.value;

    // submit時にバリデーション
    if (value && value.length > 10) {
      console.log("10文字以内で入力してください");
      return;
    }
    console.log(value);
  };

  return (
    <div>
      // refオブジェクトをinput要素に紐付け、DOMを監視させる。
      <input type="text" ref={ref} />
      <button onClick={handleSubmitClick}>確定</button>
    </div>
  );
};

比較

基本的にどちらにも一長一短があるといった感じですね。まとめると以下のような違いがあるみたいです。

パターン 非制御 制御
1回限りの値の取得(例:submit 時)
submit 時のバリデーション
入力中にリアルタイムでバリデーション
入力状態によって submit ボタンを disable する
入力のフォーマットを強制する
複数フィールドの入力で一つのデータを送る (姓名など)
dynamic inputs(動的にフィールドを増減させられるフォームなど)

引用元:Controlled and uncontrolled form inputs in React don't have to be complicated

React Hook Form について

ここで、フォームを扱う際によく用いられるライブラリである 「React Hook Form」 に目を向けてみましょう。

https://www.react-hook-form.com/

実は React Hook Form は 内部で非制御コンポーネントが採用されており、少ないコード量で高パフォーマンスの Form 実装が実現できるようになっています

さきほどの非制御用のフォームを React Hook Form で書き直すと以下のようになります。

const FormWithRHF = () => {
  const { register, handleSubmit } = useForm<IFormInput>();
  // 登録に必要な属性を取得
  const { onChange, name, ref } = register("inputValue");

  // submit時に実行する
  const onSubmit = (data: IFormInput) => {
    // submit時にバリデーション
    if (data.inputValue && data.inputValue.length > 10) {
      console.log("10文字以内で入力してください");
      return;
    }
    console.log(data.inputValue);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input name={name} onChange={onChange} ref={ref} />
      <button type="submit">確定</button>
    </form>
  );
};

export default FormWithRHF;

上記のように(useFormから得られる)register 関数を呼び出して入力名を与えると、以下のような値が返ってきます。

Name Type Description
onChange ChangeHandler onChange props は、入力の change イベントを購読します。
onBlur ChangeHandler onBlur props は入力の blur イベントを受信します。
ref React.ref<any> 登録するために必要な ref オブジェクト
name string 登録名

https://www.react-hook-form.com/api/useform/register/

これらを入力フォームの各属性に設定することで、React Hook Form と連携して便利な機能が使えるようになります。

例えば、先ほどの「制御コンポーネント」で紹介したようなリアルタイムなバリデーションも行うことが可能です。

import React from "react";
import { useForm } from "react-hook-form";

interface IFormInput {
  inputValue: string;
}

const ControlledFormWithRHF = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<IFormInput>({ mode: "onChange" });
  console.log("FormWithRHF");

  const { onChange, name, ref } = register("inputValue", {
    maxLength: 10, // ここでバリデーションを設定
  });

  // submit時に実行する
  const onSubmit = (data: IFormInput) => {
    if (data.inputValue.length > 10) {
      return;
    }
    console.log(data.inputValue);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input name={name} onChange={onChange} ref={ref} />
      {errors.inputValue && <p>10文字以上で入力してください。</p>}
      <button type="submit">確定</button>
    </form>
  );
};

export default ControlledFormWithRHF;

さらにこの実装で特筆すべきは、不必要な再レンダリングが抑えられているということです。

制御コンポーネントでは、1 文字入力されるたびにレンダリングが行われていましたが、React Hook Form を用いた実装では以下のように初回のレンダリング時とエラーメッセージ表示時のみにしかレンダリングは発生しません。

Image from Gyazo

このようにパフォーマンス面を担保した上で、リアクティブなフォームを実現できるというのが React Hook Form の最大の魅力であるわけです。
(トップページにもこういったことはどデカく表記されています 😇)


さてここで、次のような疑問をもった方も多いのではないでしょうか?

「なぜ React Hook Form は非制御コンポーネントであるにも関わらず、入力されるたびにバリデーションのチェックを行うといったような『リアクティブなフォーム』が実現可能なのか?」

もう少し突っ込んだ言い方をすると

「再レンダリングを最適化した上でどのようにリアクティブなフォームを実現させているのか?」

そこで登場するのが 「Subscription ベースの状態管理」 になります。

Subscription ベースの状態管理

React Hook Form は再レンダリングを細かく制御するために Redux や Recoil などでも採用されている Subscription ベースの状態管理を採用しています。

Subscription ベースの状態管理とは簡単にいうと、「あるコンポーネントで値の変更をサブスクライブ(購読)して、受け取った値に応じて状態を更新する」 というものです。

これだけだと少し分かりづらいと思うので、順を追って説明していきます。

オブザーバパターン

まずこの 「Subscription ベースの状態管理」の基となっている考え方が 「Observer Pattern」 と呼ばれるものです。

詳細は省きますが、ざっくりいうと観察される側(=Subject)と観察する側(=Observer)の 2 つの役割が存在し、Subject の状態が変化した際に Observer に通知されるデザインパターンのことを言います。

https://www.patterns.dev/posts/observer-pattern

▼ 日本語版はこっち
https://zenn.dev/morinokami/books/learning-patterns-1/viewer/observer-pattern

ただどちらかというと「観察」というよりも「通知」に重点が置かれているため、「Publication-Subscribe」パターンと呼ばれることも結構あるみたいです。
(自分的にはこっちのほうがしっくり来る...)

オブザーバパターンの登場人物としては主に以下のようなものが存在しています。

名称 説明
Subject(Observerable) イベントを通知する側のインタフェース
Observer イベントを通知される側のインタフェース
subscribe() Observer を 追加するためのメソッド(イベントを新たに購読する)
unsubscribe() Observer を削除するメソッド(イベントの購読をやめる)

イベントの通知と購読という文脈で考えると、どれもそんなに違和感がないですね。

そして実際にオブザーバパターンを用いて簡単なアプリケーションを作成した例が次になります。(先ほど引用した pattern.dev から拝借しています。)

Observerable.js
class Observable {
  // コンストラクタでobservers配列を初期化
  constructor() {
    this.observers = [];
  }
  // 購読メソッド: 新しいオブザーバー関数をobservers配列に追加
  subscribe(f) {
    this.observers.push(f);
  }
  // 購読解除メソッド: 指定されたオブザーバー関数をobservers配列から削除
  unsubscribe(f) {
    this.observers = this.observers.filter(subscriber => subscriber !== f);
  }
  // 通知メソッド: すべてのオブザーバー関数にデータを通知
  notify(data) {
    this.observers.forEach(observer => observer(data));
  }
}

export default new Observable();
App.js
import React from "react";
import { Button, Switch, FormControlLabel } from "@material-ui/core";
import { ToastContainer, toast } from "react-toastify";
import observable from "./Observable";

// ボタンがクリックされたときに実行される関数
function handleClick() {
  // Observableを通じて、すべての購読者にメッセージを通知
  observable.notify("User clicked button!");
}

// スイッチがトグルされたときに実行される関数
function handleToggle() {
  // Observableを通じて、すべての購読者にメッセージを通知
  observable.notify("User toggled switch!");
}

// ロガー関数: 受け取ったデータをコンソールに出力
function logger(data) {
  console.log(`${Date.now()} ${data}`);
}

// Toast通知関数: 受け取ったデータをトースト通知として表示
function toastify(data) {
  toast(data, {
    position: toast.POSITION.BOTTOM_RIGHT,
    closeButton: false,
    autoClose: 2000
  });
}

// Observableにロガーとトースト通知関数を購読
observable.subscribe(logger);
observable.subscribe(toastify);

export default function App() {
  return (
    <div className="App">
      {/* ボタン: クリックされるとhandleClick関数が実行される */}
      <Button variant="contained" onClick={handleClick}>
        Click me!
      </Button>
      {/* スイッチ: トグルされるとhandleToggle関数が実行される */}
      <FormControlLabel
        control={<Switch name="" onChange={handleToggle} />}
        label="Toggle me!"
      />
      {/* トースト通知のコンポーネント */}
      <ToastContainer />
    </div>
  );
}

こちらのアプリケーションでは、ユーザーがボタンをクリックするか、スイッチを切り替えるたびにタイムスタンプと一緒にログを出力+イベントが発生したことを通知するトーストを表示しています。

ユーザーが handleClick 関数または handleToggle 関数を呼び出すたびに、これらの関数は Observablenotify メソッド経由で Subscriber(ここでは loggertoastify) に通知します。

その際、各ハンドラーから渡されたデータも同時に Subscriber に渡されています。

Observable 経由で変化を通知することで「状態変化に応じた処理」というのを非常にシンプルに書くことができるようになります。

useContext とレンダリング最適化

ではこの仕組みを用いることで、どのようにレンダリングパフォーマンスを向上させることができるのでしょうか?

ここでは useContext を用いたコードを見ながら、オブザーバパターンを利用することがパフォーマンスの向上にどう影響するのか?見ていきましょう。


useContext を使用するとよく問題になるのが、「Context(Context オブジェクトの値)の更新が原因で、不要な再レンダリングが走ってしまう」ということだと思います。

つまり、Provider 内のすべての Consumer は、Provider の value プロパティ(Context オブジェクトの値)が更新される度に再レンダリングが走ってしまうということです。

以下のコードはその典型例です。

App.tsx
import { CounterProvider } from "./CounterContext";
import { Count } from "./Count";
import { IncrementMemo } from "./Increment";
import "./styles.css";

function Middle({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>;
}

export default function App() {
  return (
    <CounterProvider>
      <Middle>
        <Count />
        <IncrementMemo />
      </Middle>
    </CounterProvider>
  );
}
Count.tsx
import { useCounterContext } from "./CounterContext";

export function Count() {
  const { count } = useCounterContext();
  return <span>Count: {count}</span>;
}
Increment.tsx
import { memo } from "react";
import { useCounterContext } from "./CounterContext";

export const IncrementMemo = memo(function Increment() {
  const { increment } = useCounterContext();
  return <button onClick={increment}>+</button>;
});
CounterContext.tsx
import { createContext, useContext, useState, useCallback } from "react";

type CounterContextType = {
  count: number;
  increment: () => void;
};

const CounterContext = createContext<CounterContextType | undefined>(undefined);
CounterContext.displayName = "CounterContext";

export function useCounterContext() {
  const value = useContext(CounterContext);

  if (!value) {
    throw new Error(
      "Counter context is undefined, please verify you are calling useCounterContext() as child of a <CounterProvider> component."
    );
  }

  return value;
}

export const CounterProvider: React.FC = ({ children }) => {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => {
    setCount((count) => count + 1);
  }, [setCount]);

  return (
    <CounterContext.Provider value={{ count, increment }}>
      {children}
    </CounterContext.Provider>
  );
};

Count コンポーネントでは Context から state のみを取得し、Increment コンポーネントでは setState のみを取得しています。

ここでIncrement コンポーネントで setState が呼ばれるたびに Provider では state が更新され、 value プロパティ全体に変更が発生します。

すると、value プロパティに変更が生じたため state を Context から取得している Count だけではなく、Increment でも再レンダリングが発生してしまいます。

この不要な再レンダリングを防ぐ手っ取り早い方法としては、Context を分割することが有名かと思います。

つまり、以下のように分割することで ProviderB の value プロパティは不変になるため、Increment コンポーネントでは state が更新されても再レンダリングが発生しなくなります。

Subscription ベースの状態管理を用いてuseContextの問題点を解消する

ここまではよくある 「Context の分割」 によるレンダリングパフォーマンスの向上について見てきました。

ただコンポーネント間で共有されるステートが複雑化してくると、不満が出てくることも多々あるでしょう。
よくあるのが、Context を分割しすぎることで生じる Provider 地獄ではないでしょうか?

また Consumer 側で再レンダリングを細かく制御したい場合など Context を単純に分割できない場合もあります。

例えば、Redux の useSelector のように、selector 関数が返す値に更新があった場合のみ再レンダリングを行うといったことを Context のみで実現することは難しいです。
(↓ こーいうやつ。)

import { useSelector } from "react-redux";

function UserProfile() {
  // useSelectorを使用して、userのnameだけを監視
  const userName = useSelector((state) => state.user.name);

  return <div>Hello, {userName}!</div>;
}



ではここに Subscription ベースの状態管理を用いてみるとどうなるでしょうか。

以下は先程のカウンターアプリを Subscription ベースの状態管理を用いて書き直した例です。

重要なのが、CounterContext.tsxcreateSubject.tsuseCountState.tsです。

CounterContext.ts
// カウンターのコンテキストの型定義
type CounterContextType = {
  subjectRef: React.MutableRefObject<Subject<number>>; // カウンターの値を持つSubjectの参照
  increment: () => void; // カウンターを増加させる関数
};

...

export function useCounterContext() {
  ...
}

export const CounterProvider: React.FC = ({ children }) => {
  // カウンターの初期値を0としてSubjectを作成し、参照として保存
  const subjectRef = useRef(createSubject(0));
  // カウンターを増加させる関数
  const increment = useCallback(() => {
    // 現在のカウンターの値を取得し、1増加させてSubjectに通知
    subjectRef.current.next(subjectRef.current.getValue() + 1);
  }, []);

  return (
    <CounterContext.Provider value={{ subjectRef, increment }}>
      {children}
    </CounterContext.Provider>
  );
};
createSubject.ts
// Observerの型定義。nextメソッドを持つオブジェクト。
type Observer<T> = {
  next: (value: T) => void;
};

// 購読解除を行うためのインターフェース。
type Subscription = {
  unsubscribe: () => void;
};

// Subjectの型定義。getValueとsubscribeメソッド、およびObserverを持つ。
export type Subject<T> = {
  getValue: () => T;
  subscribe: (value: Observer<T>["next"]) => Subscription;
} & Observer<T>;

// Subjectを作成する関数。
export function createSubject<T>(initialValue: T): Subject<T> {
  // 現在のカウンター値を保持する変数。
  let value = initialValue;
  // 購読しているObserverのリスト。
  let observers: Observer<T>[] = [];

  // 現在の値を取得する関数。
  const getValue = () => value;

  // 新しいカウンター値を設定し、すべてのObserverにその値を通知する関数。
  const next = (val: T): void => {
    value = val;
    for (const observer of observers) {
      observer.next(value);
    }
  };

   // Observerを購読する関数。購読解除を行うunsubscribeメソッドを持つオブジェクトを返す。
  const subscribe = (next: Observer<T>["next"]): Subscription => {
    const observer: Observer<T> = { next };
    observers.push(observer);
    // 指定されたObserverをリストから削除する。
    return {
      unsubscribe: () => {
        observers = observers.filter((o) => o !== observer);
      }
    };
  };

  // Subjectオブジェクトを返す。
  return {
    getValue,
    next,
    subscribe
  };
}
useCountState.ts
// カウンターの現在の値を返すカスタムフック
export function useCountState() {
  // CounterContextからsubjectRefを取得
  const { subjectRef } = useCounterContext();
  // ローカルステートとしてカウンターの値を保持
  const [count, setCount] = useState(subjectRef.current.getValue());

  useEffect(() => {
     // subjectRefの変更を購読し、新しい値が来たらsetCountでステートを更新
    const subscription = subjectRef.current.subscribe(setCount);
    // コンポーネントのクリーンアップ時に購読を解除
    return () => subscription.unsubscribe();
  }, [setCount, subjectRef]);

  // カウンターの現在の値を返す
  return count;
}

ここで、オブザーバパターンで紹介した主要登場人物たち以外にも 1 名見慣れない顔がいるのに気づいたでしょうか?

それが_nextです。

これは Subject にも Object にも存在するメソッドとなっています。
イメージとしては

  • Subject の_next()何かしらのイベントを Observer 全員に通知するもの(渡したいものがある場合は一緒に渡す)。 前の例だとnotifyにあたる
  • Observer の_next()何かしらのイベントの通知を受けた際に Observer 側で何をするのか?を定めたもの

という違いあります。

createSubject.ts
// Observerの型定義。nextメソッドを持つオブジェクト。
type Observer<T> = {
  next: (value: T) => void;
};

...

// Subjectの型定義。getValueとsubscribeメソッド、およびObserverを持つ。
export type Subject<T> = {
  getValue: () => T;
  subscribe: (value: Observer<T>["next"]) => Subscription;
} & Observer<T>;

そして 今回の例だと Count コンポーネントが初回マウントされたとき subscribe の引数として setCount が渡されています。
これにより、Subject が新しい値を発行した際には、 setCount でステートが更新され再レンダリングが走るようになっています。

useCountState.ts
...
  useEffect(() => {
     // subjectRefの変更を購読し、新しい値が来たらsetCountでステートを更新
    const subscription = subjectRef.current.subscribe(setCount);
    // コンポーネントのクリーンアップ時に購読を解除
    return () => subscription.unsubscribe();
  }, [setCount, subjectRef]);

当然これだと Subscribe しているコンポーネントでしか再レンダリングは発生しないため、useContextで発生したような不要な再レンダリングは起こりませんし、また購読側で細かく再レンダリングを制御できるようになっています。

Subscription ベースの状態管理を行うことで、レンダリングが最適化されていることがわかるかと思います。

React Hook Form は非制御コンポーネントからどのように変更を検知しているのか?

では Subscription ベースの状態管理の方法がわかったところで、実際に React Hook Form の内部でこれらがどう用いられているのか?見ていきましょう。

パフォーマンスとリアクティブ性を両立している典型例がwatchuseWatchですので、この 2 つの実装例を比較しながら具体的に見ていきましょう。

watch

まずwatchについて。

watchとは名前の通り、指定された入力属性のフォームを監視して変更があるたびにその値を返す関数です。

https://www.react-hook-form.com/api/useform/watch/

以下の例を見てみましょう。

import React from "react";
import { useForm } from "react-hook-form";

interface IFormInputs {
  name: string
  showAge: boolean
  age: number
}

function App() {
  const { register, watch, formState: { errors }, handleSubmit } = useForm<IFormInputs>();
  const watchShowAge = watch("showAge", false);

  const onSubmit = (data: IFormInputs) => console.log(data);

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input {...register("name", { required: true, maxLength: 50 })} />
        <input type="checkbox" {...register("showAge")} />
        {/* showAgeがtrueになると年齢の入力欄が表示される*/}
        {watchShowAge && (
          <input type="number" {...register("age", { min: 50 })} />
        )}
        <input type="submit" />
      </form>
    </>
  );
}

ここでは年齢表示のチェックボックスにチェックが入ったかどうかを監視しています。

チェックが入ったらリアルタイムにその値を反映させて、年齢入力欄を表示するという仕様になっています。

useWatch

次にuseWatchです。

基本的にはwatchと同様に値の監視を行うものですが、ひとつ違う点がカスタムフックレベルで再レンダリングが分離されるということです。

https://www.react-hook-form.com/api/usewatch/

これだけだとイメージしづらいので、例を見てみましょう。
先ほどのフォームをuseWatchを用いたパフォーマンス面に考慮した書き方に変更してみましょう。

App.tsx
export function App() {
  const {
    register,
    control,
    formState: { errors },
    handleSubmit,
  } = useForm<IFormInputs>();

  const onSubmit = (data: IFormInputs) => {
    alert(JSON.stringify(data));
  };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>Name</label>
      <input
        type="text"
        {...register("name", { required: true, maxLength: 50 })}
      />
      {errors.name && (
        <p>{"The Name Field is Required and must be > 49 characters"}</p>
      )}
      <label>Show Age</label>
      <input type="checkbox" {...register("showAge")} />

      {/* AgeFormに購読用のコンポーネントを切り出す */}
      <AgeForm name="showAge" control={control}>
        {(showAgeValue) => {
          return (
            showAgeValue && (
              <>
                <label>Age</label>
                <input type="number" {...register("age", { min: 50 })} />
                {errors.age && <p>{"The number must be greater then 49"}</p>}
              </>
            )
          );
        }}
      </AgeForm>
      <input type="submit" />
    </form>
  );
}
AgeForm.tsx
type AgeFormProps = {
  name: keyof IFormInputs;
  control: Control<IFormInputs>;
  children: (showAgeValue: boolean | number | string) => ReactNode;
};

export function AgeForm({ name, control, children }: AgeFormProps) {
  {/* useWatchで値を監視 **/}
  const showAgeValue = useWatch({ name, control });
  {/* showAgeチェック時に、再描画されるのはここのみ **/}
  return <>{children(showAgeValue)}</>;
}

AgeForm コンポーネントを購読用のコンポーネントとして切り出して、その中でuseWatchを利用して値を監視するようにしました。

こうすることで先程だとチェックボックスにチェックが入るたびに App コンポーネント全体が再描画されていたのが、チェックが入ったか否かの情報を必要とするコンポーネント(AgeForm)のみを再描画するようになりました。

つまり、watchuseFormを使用しているルートレベルのコンポーネントが再更新されるのに対して、useWatchはカスタムフックが使用されるコンポーネントのみの再更新に抑えることができるという違いがあります。

変更検知のメカニズム

ではこの useWatch の変更検知のメカニズムはどのように行われているのでしょうか?

以下は、watchuseWatch の簡易的な実装例になります。
(こちらのコードは@kotarella1110様の CodeSandBox から拝借させていただいております。)

この実装の核となるのが、useForm.tsuseWatch.ts です。

まずuseForm.tsについて。

useForm.ts
...

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
      }),
      []
    )
  };
}

こちらでは名前の通り、React Hook Form のuseFormがカスタムフックとして定義されています。詳しく見ていきましょう。

const rerender = useReducer(() => ({}), {})[1];
const fieldsRef = useRef<Fields<TFieldValues>>({} as Fields<TFieldValues>);
const watchingNamesRef = useRef<(keyof TFieldValues)[]>([]);
const subjectRef = useRef(createSubject<{ name: string }>());

後述で用いられる関数や変数が定義されています。

rerenderですが、こちらはこのuseFormの呼び出し元コンポーネントで再レンダリングを発生させる関数です。
また fieldsRefwatchingNamesRefsubjectRef ですが

  • fieldsRefregister関数を渡して登録したフィールドの DOM 要素の参照を保持するオブジェクト
  • watchingNamesRef:監視しているフィールドの名前のリストを保持するオブジェクト
  • subjectRef:Subject

のようになっています。

次にregisterです。

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]
);

全体としてはname属性の値を引数にとって、namerefコールバック、onChangeハンドラを返すようになっています。
(本当だったらonBlurもありますが、簡易的な実装であるため省略されています。)

  const register = useCallback(
    (name: keyof TFieldValues) => {
      ...
      return {
        name,
        ref,
        onChange,
      } as ReturnType<UseFormReturn<TFieldValues>["register"]>;
    },
    [rerender]
  );

さてここで、refが関数として返されることに疑問を持った方も多いでしょう。

const ref = <
  TElement extends HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>(
  node: TElement | null
) => {
  if (node) fieldsRef.current[name] = node;
};

これはいわゆるrefコールバックと呼ばれるもので、ref をアタッチする際に 呼び出される関数で、DOM 要素 もしくは null を引数に取ります。

https://ja.react.dev/reference/react-dom/components/common#ref-callback

これにより、ref を渡した先の DOM 要素が fieldsRefに追加されていきます。
(↓ こんな感じ)

そして1番重要なのが、次のonChangeハンドラです。

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

これはフィールドの値が変更された際に呼び出される関数で、もし変更されたフィールド名がwatchingNamesRefに含まれていれば再レンダリングが走ります。

その後、Subject から Observer にフィールド名を渡して変更を通知します。受け取った側はこのフィールド名が監視対象のフィールド名と一致するかどうか見て、再レンダリングを走らせるか決めます。

そしてwatchです。

const watch = useCallback((name: keyof TFieldValues) => {
  watchingNamesRef.current.push(name);
  return fieldsRef.current[name]?.value;
}, []);

watchは非常にシンプルな実装になっています。フィールド名を引数にとって、watchingNamesRefに追加 → そのフィールドの value を返します。

最後にuseFormが返すオブジェクトを定義しています。

return {
  register,
  watch,
  control: useMemo(
    () => ({
      fieldsRef,
      subjectRef,
    }),
    []
  ),
};

controlの中にfieldsRefsubjectRefが含まれています。つまり、control経由で Subject などは呼び出されるわけです。


次にuseWatch.tsについて

useWatch.ts
...

export function useWatch<TFields extends Record<string, any>>({
  name,
  control
}: Props<TFields>): undefined | string {
  const rerender = useReducer(() => ({}), {})[1];

  useEffect(() => {
    const subscription = control.subjectRef.current.subscribe((value) => {
      if (value.name === name) rerender();
    });
    return () => subscription.unsubscribe();
  }, [name, control.subjectRef, rerender]);

  return control.fieldsRef.current[name]?.value;
}

useEffect 内が重要です。

const subscription = control.subjectRef.current.subscribe((value) => {
  if (value.name === name) rerender();
});

これにより、フィールドの値の変更を購読しています。

Subject から変更を通知されるたびに subscribe 以下のコールバック関数が走り、変更されたフィールド名が監視対象のフィールド名と一致する場合、rerender を呼び出してコンポーネントが再レンダリングされます。


こうしてみると、watchはシンプルにuseFormの呼び出し元のコンポーネントを再レンダリングする一方で、useWatchはこのフックの呼び出し元コンポーネントのみを再レンダリングするという違いがあるのがわかることでしょう。

このようにuseWatchはオブザーバパターンを用いて Subject から変更を通知するような仕組みを取ることで、レンダリングパフォーマンスを最適化しています。

まとめ

長々と話してきましたが、以上が React Hook Form の変更検知のメカニズムでした。

「Subsctiption ベースの状態管理を採用することによりリアルタイムに変更を検知しつつも、コンポーネントで使用されている状態のみを更新して再レンダリングを最適化している」 というのは改めて見ると本当に面白い仕組みです。

他にも React Hook Form はformStateなどでも再レンダリングを抑えるため色々な工夫があるみたいなので、時間があったらコードを読んでみたいですね。

最後までご覧いただきありがとうございました!

参考文献

https://speakerdeck.com/kotarella1110/react-hook-form-hadofalseyounizai-rendaringuwozui-shi-hua-siteirufalseka

https://zenn.dev/takepepe/articles/rhf-usewatach

https://zenn.dev/morinokami/books/learning-patterns-1/viewer/observer-pattern

https://qiita.com/shoheiyokoyama/items/d4b844ed29f84a80795b

COUNTERWORKS テックブログ

Discussion

Honey32Honey32

失礼します。

非常に細かい点ですが、React 公式が制御コンポーネントを推していたのは、旧ドキュメントの方で、新ドキュメントでは軟化して中立的な書かれ方になっています。

公式の意図を勝手に想像すると、非制御コンポーネントは、そのままでは柔軟さには欠けるものの、一定の条件下では十分に使えるということが分かったから妥協したのかも。(制御コンポーネントの弱点をある程度おぎなえている React Hook Form の存在も公式の認識の変化に貢献したのかも知れません)

https://ja.react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components

https://ja.react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable

Yusuke InaiYusuke Inai

ご指摘ありがとうございます!
たしかに新ドキュメントの方には「制御コンポーネント」を推奨するような記述が見つからなかったため、こちらの記述の方を削除させていただきました。

water_bubblewater_bubble

「なぜ React Hook Form は非制御コンポーネントであるにも関わらず、入力されるたびにバリデーションのチェックを行うといったような『リアクティブなフォーム』が実現可能なのか?」

の導入部分ではwatchを使った事例ではないと思いますが、formの各inputのref.currentの値をobserverパターンでリアルタイムに内部的に確認してvalidation error等があれば、useContextでそのinputのcomponentだけを特定しながらuseReducerでrenderする(watchを使った変更検知と大体同じ)みたいな理解で良いでしょうか?