⛩️

react-hook-formのuseWatch周りでハマった際の記録

2024/06/25に公開
1

概要

先日react-hook-formuseWatchまわりで試行錯誤するケースがあったため、備忘録がてら記録に残しておこうと思います。

useWatchとは

公式Documentは下記になります。
https://react-hook-form.com/docs/usewatch

Formの変更内容を検知するためのカスタムフックです。useFormの戻り値として得られるwatchという関数も似たような用途に使えますが、useWatchの方がレンダリングの範囲を狭められるためパフォーマンス的に優れているようです。

使用例

※公式Documentより抜粋

example
interface FormInputs {
  firstName: string
  lastName: string
}

function FirstNameWatched({ control }: { control: Control<FormInputs> }) {
  const firstName = useWatch({
    control,
    name: "firstName", // without supply name will watch the entire form, or ['firstName', 'lastName'] to watch both
    defaultValue: "default", // default value before the render
  })

  return <p>Watch: {firstName}</p> // only re-render at the custom hook level, when firstName changes
}

引数に要素名を渡すことで特定のComponentの変更を検知することもできますし、特に指定しなければForm全体の変更を検知することもできます。

やりたかったこと

Form内にある、ある一つの要素以外の全ての変更を検知したい。具体的には、ユーザがFormの内容を変更したら、確認用のチェックボックスをOffにしたい。

イメージ

例えば上記の住所や置き配オプションに変更があった場合、「上記の内容を確認した」チェックを自動的に外したいとします。この場合、下記のようなカスタムフックを作成してFormProvider内のComponentで使用すれば目的を果たすことができます。

単純な例
/**
 * 住所又は置き配オプションに変更があった場合に、「上記の内容を確認した」CheckBoxをOffにする
 * useFormContext()を使用するため、FormProvider内のComponentで呼ぶ必要あり
 */
export const useConfirmationCheck = () => {
  // 監視したい要素名を指定してuseWatchに渡す
  const updatedValues = useWatch({name: ['address', 'okihaiOption']})
  const { setValue } = useFormContext()

  useEffect(() => {
    // 変更を検知したら確認用チェックボックスをOffにする
    setValue('confirmationCheck', false)
  }, [updatedValues])
}

やっていることはシンプルで、useWatchに監視したい要素のnameを渡し、useEffectで変更を検知したら確認用チェックボックスをOffにするだけです。updatedValuesには変更後のFormの内容がオブジェクトとして返ってくるので、必要であれば変更後の値を参照することもできます。

このように要素数の少ないFormであれば、useWatchに監視したい要素名のリストを渡せば済むのですが、監視対象の要素が増えた場合はどうなるでしょうか。例えば名前や電話番号等、useWatchに渡したい要素が増えた場合、下記のようになります。

監視対象の要素が多い場合
export const useConfirmationCheck = () => {
  // 監視したい要素名を指定してuseWatchに渡す
  const updatedValues = useWatch({
    name: [
      'firstName',
      'familyName',
      'age',
      'sex',
      'phone',
      'email',
      'country',
      'prefecture',
      'city',
      'zipCode',
      'address',
      'okihaiOption'
    ]
  })
  const { setValue } = useFormContext()
  
  useEffect(() => {
    // 変更を検知したら確認用チェックボックスをOffにする
    setValue('confirmationCheck', false)
  }, [updatedValues])
}

これだけならまだしも、要素数が百以上になるようなFormの場合はお手上げです。要素の追加や削除の度に変更が必要になりますし、保守性の観点からも好ましくなさそうです。そこで最初に考えたのが、useWatchに要素名を渡さず、form全体の変更を検知する方法です。

失敗例

全部監視からの無限ループ
export const useConfirmationCheck = () => {
  // 面倒なので、まとめて全部監視してしまえ!
  const updatedValues = useWatch()
  const { setValue } = useFormContext()

  // 変更を検知 → チェックボックスをOff → 変更を検知 の無限ループに、、
  useEffect(() => {
    setValue('confirmationCheck', false)
  }, [updatedValues])
}

お分かり頂けただろうか。いちいち監視対象を指定するのが面倒だからと安易に全体を監視してしまうと、setValueで変更した確認用チェックボックスの変更も検知してしまい、検知→変更→検知の無限ループ地獄に陥ってしまいます。そこで確認用チェックボックスの変更だけは無視するよう、直前のForm内容を保持して比較できるようにしたのが下記の回避策です。

回避策

直前のForm内容を保持し、変更箇所と比較することで無限ループを回避
export const useConfirmationCheck = () => {
  // 一旦全部監視対象にする
  const updatedValues = useWatch()
  // 直前のFormの状態を保持する
  const prevValues = useRef<FieldValues>({});
  const { setValue } = useFormContext()

  useEffect(() => {
    // Formの内容に変更が無い場合は何もしない
    if (isEqual(updatedValues, prevValues.current)) {
      return
    }

    if (updatedValues['confirmationCheck'] !== prevValues.current.confirmationCheck) {
      // 確認用CheckBoxの状態に変更がある場合、prevValuesを更新して終了する
      prevValues.current = cloneDeep(updatedValues)
      return
    }

    // 確認用CheckBox以外の状態が変更された場合のみ、確認用CheckBoxをOffにする
    setValue('confirmationCheck', false)
  }, [updatedValues])
}

ここで注意が必要なのが、

Objectの参照渡し
prevValues.current = updatedValues

のように単純に参照渡しすると、意図せずprevValuesの中身が更新されてしまう場合があるため、Objectの中身をdeep copyする必要がある点です。同様に、子要素がNestされたObject同士を比較する際、=====等の等価演算子では意図した比較にならない場合があるため、全ての子要素を走査した上で実質的に同じObjectかどうかを比較するDeep Comparisonが必要になります。ObjectのDeep CopyやDeep Comparisonは自分で実装するとそれなりに面倒なので、筆者はlodashというライブラリを使用しました。

終わりに

正直、上記の回避策がベストなのか今でも分かりません。無視したい要素を除外する処理をミスると、やはり無限ループに陥る危険性があるため、もっと良い方法があればそちらに切り替えた方が良いのではと考えています。同じような事例に遭遇したけどもっと良い方法があるよ!という方がいらっしゃれば是非アドバイス頂けるとありがたいです。

CureApp テックブログ

Discussion

nap5nap5

やはり無限ループに陥る危険性があるため、もっと良い方法があればそちらに切り替えた方が良いのではと考えています。同じような事例に遭遇したけどもっと良い方法があるよ!という方がいらっしゃれば是非アドバイス頂けるとありがたいです。

プログラマティックに操作したいプロパティ以外をdequalで比較して、その値をもとに、既存のデフォルト値から変更があればfalseに、変更がなければデフォルト値の値に戻すようにしてみました