React Hook Formは非制御コンポーネントからどのように変更を検知しているのか?
はじめに
React Hook Form が 「非制御コンポーネント」 としてフォームを扱うことでパフォーマンスなどを最適化しているのは有名ですが、 「watch
や useWatch
で値が入力されるたびにどのように変更を検知しているのか?」 などについては意外と知っている人が少なそうだったのでまとめてみました。
どなたかの参考になれば幸いです。ぜひ最後までご覧ください。
「非制御コンポーネント」とはなにか?
本題に入る前に、まず React Hook Form を語る上では欠かせない 「制御コンポーネント」「非制御コンポーネント」 について軽く触れておきます。
制御コンポーネント
まず「制御コンポーネント」とは一言で言うなら 「入力要素の状態を React(state)が保持するコンポーネント」 のことです。
メリットとしては常に値にアクセスできるため、「ユーザが入力中にバリデーションを実施する」といったリアクティブなフォームを作成できます。
デメリットとしては 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 自身が保持するコンポーネント」 のことです。
メリットとしては state を経由しないため、入力値の更新毎に 再レンダリングが発生しません。 このため、パフォーマンス面では制御コンポーネントよりも優れています。
デメリットとしては必要なタイミング(submit 時など)で DOM から入力値を取得するため、 「入力中にバリデーションを実施する」 といったリアルタイム性が必要な実装が難しいことが挙げられます。
具体的な実装例としては以下のようになります。基本的にはuseRef
とref
属性を利用していきます。
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」 に目を向けてみましょう。
実は 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 | 登録名 |
これらを入力フォームの各属性に設定することで、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 を用いた実装では以下のように初回のレンダリング時とエラーメッセージ表示時のみにしかレンダリングは発生しません。
このようにパフォーマンス面を担保した上で、リアクティブなフォームを実現できるというのが React Hook Form の最大の魅力であるわけです。
(トップページにもこういったことはどデカく表記されています 😇)
さてここで、次のような疑問をもった方も多いのではないでしょうか?
「なぜ React Hook Form は非制御コンポーネントであるにも関わらず、入力されるたびにバリデーションのチェックを行うといったような『リアクティブなフォーム』が実現可能なのか?」
もう少し突っ込んだ言い方をすると
「再レンダリングを最適化した上でどのようにリアクティブなフォームを実現させているのか?」
そこで登場するのが 「Subscription ベースの状態管理」 になります。
Subscription ベースの状態管理
React Hook Form は再レンダリングを細かく制御するために Redux や Recoil などでも採用されている Subscription ベースの状態管理を採用しています。
Subscription ベースの状態管理とは簡単にいうと、「あるコンポーネントで値の変更をサブスクライブ(購読)して、受け取った値に応じて状態を更新する」 というものです。
これだけだと少し分かりづらいと思うので、順を追って説明していきます。
オブザーバパターン
まずこの 「Subscription ベースの状態管理」の基となっている考え方が 「Observer Pattern」 と呼ばれるものです。
詳細は省きますが、ざっくりいうと観察される側(=Subject)と観察する側(=Observer)の 2 つの役割が存在し、Subject の状態が変化した際に Observer に通知されるデザインパターンのことを言います。
▼ 日本語版はこっち
ただどちらかというと「観察」というよりも「通知」に重点が置かれているため、「Publication-Subscribe」パターンと呼ばれることも結構あるみたいです。
(自分的にはこっちのほうがしっくり来る...)
オブザーバパターンの登場人物としては主に以下のようなものが存在しています。
名称 | 説明 |
---|---|
Subject(Observerable) | イベントを通知する側のインタフェース |
Observer | イベントを通知される側のインタフェース |
subscribe() | Observer を 追加するためのメソッド(イベントを新たに購読する) |
unsubscribe() | Observer を削除するメソッド(イベントの購読をやめる) |
イベントの通知と購読という文脈で考えると、どれもそんなに違和感がないですね。
そして実際にオブザーバパターンを用いて簡単なアプリケーションを作成した例が次になります。(先ほど引用した pattern.dev から拝借しています。)
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();
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
関数を呼び出すたびに、これらの関数は Observable
の notify
メソッド経由で Subscriber(ここでは logger
と toastify
) に通知します。
その際、各ハンドラーから渡されたデータも同時に Subscriber に渡されています。
Observable 経由で変化を通知することで「状態変化に応じた処理」というのを非常にシンプルに書くことができるようになります。
useContext とレンダリング最適化
ではこの仕組みを用いることで、どのようにレンダリングパフォーマンスを向上させることができるのでしょうか?
ここでは useContext
を用いたコードを見ながら、オブザーバパターンを利用することがパフォーマンスの向上にどう影響するのか?見ていきましょう。
useContext
を使用するとよく問題になるのが、「Context(Context オブジェクトの値)の更新が原因で、不要な再レンダリングが走ってしまう」ということだと思います。
つまり、Provider 内のすべての Consumer は、Provider の value
プロパティ(Context オブジェクトの値)が更新される度に再レンダリングが走ってしまうということです。
以下のコードはその典型例です。
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>
);
}
import { useCounterContext } from "./CounterContext";
export function Count() {
const { count } = useCounterContext();
return <span>Count: {count}</span>;
}
import { memo } from "react";
import { useCounterContext } from "./CounterContext";
export const IncrementMemo = memo(function Increment() {
const { increment } = useCounterContext();
return <button onClick={increment}>+</button>;
});
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
が更新されても再レンダリングが発生しなくなります。
useContext
の問題点を解消する
Subscription ベースの状態管理を用いてここまではよくある 「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.tsx
とcreateSubject.ts
、useCountState.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>
);
};
// 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
};
}
// カウンターの現在の値を返すカスタムフック
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 側で何をするのか?を定めたもの
という違いあります。
// 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
でステートが更新され再レンダリングが走るようになっています。
...
useEffect(() => {
// subjectRefの変更を購読し、新しい値が来たらsetCountでステートを更新
const subscription = subjectRef.current.subscribe(setCount);
// コンポーネントのクリーンアップ時に購読を解除
return () => subscription.unsubscribe();
}, [setCount, subjectRef]);
当然これだと Subscribe しているコンポーネントでしか再レンダリングは発生しないため、useContext
で発生したような不要な再レンダリングは起こりませんし、また購読側で細かく再レンダリングを制御できるようになっています。
Subscription ベースの状態管理を行うことで、レンダリングが最適化されていることがわかるかと思います。
React Hook Form は非制御コンポーネントからどのように変更を検知しているのか?
では Subscription ベースの状態管理の方法がわかったところで、実際に React Hook Form の内部でこれらがどう用いられているのか?見ていきましょう。
パフォーマンスとリアクティブ性を両立している典型例がwatch
とuseWatch
ですので、この 2 つの実装例を比較しながら具体的に見ていきましょう。
watch
まずwatch
について。
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
と同様に値の監視を行うものですが、ひとつ違う点がカスタムフックレベルで再レンダリングが分離されるということです。
これだけだとイメージしづらいので、例を見てみましょう。
先ほどのフォームをuseWatch
を用いたパフォーマンス面に考慮した書き方に変更してみましょう。
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>
);
}
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
)のみを再描画するようになりました。
つまり、watch
はuseForm
を使用しているルートレベルのコンポーネントが再更新されるのに対して、useWatch
はカスタムフックが使用されるコンポーネントのみの再更新に抑えることができるという違いがあります。
変更検知のメカニズム
ではこの useWatch
の変更検知のメカニズムはどのように行われているのでしょうか?
以下は、watch
と useWatch
の簡易的な実装例になります。
(こちらのコードは@kotarella1110様の CodeSandBox から拝借させていただいております。)
この実装の核となるのが、useForm.ts
、useWatch.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
の呼び出し元コンポーネントで再レンダリングを発生させる関数です。
また fieldsRef
と watchingNamesRef
、subjectRef
ですが
-
fieldsRef
:register
関数を渡して登録したフィールドの 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
属性の値を引数にとって、name
、ref
コールバック、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 を引数に取ります。
これにより、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
の中にfieldsRef
とsubjectRef
が含まれています。つまり、control
経由で Subject などは呼び出されるわけです。
次に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
などでも再レンダリングを抑えるため色々な工夫があるみたいなので、時間があったらコードを読んでみたいですね。
最後までご覧いただきありがとうございました!
参考文献
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion
失礼します。
非常に細かい点ですが、React 公式が制御コンポーネントを推していたのは、旧ドキュメントの方で、新ドキュメントでは軟化して中立的な書かれ方になっています。
公式の意図を勝手に想像すると、非制御コンポーネントは、そのままでは柔軟さには欠けるものの、一定の条件下では十分に使えるということが分かったから妥協したのかも。(制御コンポーネントの弱点をある程度おぎなえている React Hook Form の存在も公式の認識の変化に貢献したのかも知れません)
ご指摘ありがとうございます!
たしかに新ドキュメントの方には「制御コンポーネント」を推奨するような記述が見つからなかったため、こちらの記述の方を削除させていただきました。
の導入部分ではwatchを使った事例ではないと思いますが、formの各inputのref.currentの値をobserverパターンでリアルタイムに内部的に確認してvalidation error等があれば、useContextでそのinputのcomponentだけを特定しながらuseReducerでrenderする(watchを使った変更検知と大体同じ)みたいな理解で良いでしょうか?