React Hook Form v7とMaterial UIを組み合わせるときの挙動

2021.05.26

Web アプリケーションを作るときにフォームは避けることができない問題の1つです. 私はReact Hook Form (RHF)をよく利用してフォームを構築します. RHF v7とMaterial UI v4のTextFieldを結合したときにうまく動いたり動かなかったりして少し困ったのでそこの原因とワークアラウンドについてかければと思っています.

environment

  • React: 17.0.x
  • React Hook Form: 7.6.x
  • Material UI: 4.11.x

fundamental knowledge

React Hook Form(RHF) v7ではregister関数の返り値をフォーム要素に渡すことで繋ぎ込みを行います. v6ではrefに対して直接registerを渡していました.

<input
  name="name"
  ref={register}
/>

v7ではregister関数が {onChange, onBlur, name, ref} を返すため, それをフォーム要素に渡す形になっています. つまりドキュメント通りですね.

const Greeting = () => {
  const {register} = useForm();

  return (
    <form>
      <input {...register('name')} />
    </form>
  );
};

register関数の出力をスプレッドしてpropsとして渡していました. この部分をスプレッド構文を利用せずに書くとこのようになります.

const Greeting = () => {
  const {register} = useForm();
  const {ref, onChange, onBlur, name } = register('name');

  return (
    <form>
     <input id="name" name={name.name} onChange={name.onChange} ref={ref} onBlur={onBlur} />
    </form>
  );
};

このときnameとonChangeとrefをpropsとして渡すと当然動作します.
また下記のようにnameとonChangeのみを渡しただけの場合もこのケースでは動作します.
RFH v7では初期値が渡されていなくてかつ, refが渡っていない場合はoChangeイベントのvalueがフォールバックとして使用されます.
なので下記のコードは結果としてフォーム要素への入力が正しく反映されます.

const Greeting = () => {
  const {register} = useForm();
  const {ref, onChange, onBlur, name } = register('name');

  return (
    <form>
     <input id="name" name={name.name} onChange={name.onChange} />
    </form>
  );
};

しかしuseFormでデフォルト値を指定するとフォールバックが作動しないため, onChangeイベントのvalueが使われません. そのためsubmitしたときの値は常に初期値が表示されます.

const Greeting = () => {
  // defaultValues cause the wrong behavior
  const {register} = useForm({defaultValues: {name: 'john'}});
  const {ref, onChange, onBlur, name } = register('name');

  return (
    <form>
     <input id="name" name={name.name} onChange={name.onChange} />
    </form>
  );
};

コードサンプルと言葉だけでは少し伝わりづらいので, 実際に動かしてみると良いかと思います...

problems with Material UI

これを踏まえてMaterial UIのTextFieldと組み込む場合を考えます. 前と同じような書き方をするとこうなりますね.

<TextField {...register('name')}/>

/** this turns as */
/** <TextField name={name} ref={ref} onChange={onChange} onBlur={onBlur} /> */

TextFieldではref propを渡すとそのコンポーネントのトップにあるdiv要素にフォワーディングします. input要素にフォワーディングしたい場合はinputRefを経由して渡す必要があります.
つまりこの書き方の場合にはrefオブジェクトが正しくフォワーディングされないため, 下記の挙動のいずれかの振る舞いをしてしまいます.

  • 初期値が指定されている場合は正しく動作する
  • 初期値が指定されていない場合は正しく動作しない

これを解決するためにTextFieldではinputRefにrefオブジェクトを渡す必要があります.
愚直に行うと const name = register('name') みたいなコードをコンポーネントの頭に要素数だけ書いてそれを渡すみたいな形になってしまいます. かなり厄介なのでもう少し良い方法を考えていきましょう.

solutions

最良な方法があれば是非とも教えてください. とりあえず私が考えた方法をのせていきます.

wrap TextField

TextFieldを少しカスタマイズしてRHFから制御コンポーネントとして扱えるようにします. RHFではUIとロジックを分離しやすいように制御コンポーネントの構築でもuseController hookを提供しているのでこれを活用します.
useControllerは controlnamerules を引数にとって, refオブジェクトなどを返してくれます. なのでこれらの処理を親側で行うのではなく, 末端のコンポーネントで行うことでinputRefに対して適切にrefオブジェクトを渡すことができます.

export const CustomTextField: React.VFC<CustomTextFieldProps> = (props) => {
  const { name, control, rules, ...textFieldProps } = props
  const {
    field: { ref, ...rest },
  } = useController({ name, control, rules })

  return (
    <TextField
      inputRef={ref}
      {...rest}
      {...textFieldProps}
    />
  )
}

interface CustomTextFieldProps extends StandardTextFieldProps {
  control: Control<any>
  name: Path<any>
  rules?: Omit<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>
}

イメージが湧きづらいので呼び出し側についても記載します. TextFieldへ渡すpropsと先ほど定義したpropsを一緒に渡すだけです. 呼び出し側での処理がとても簡単になるのも良いなぁと思っています.

const Greeting = () => {
  const {control} = useForm({defaultValues: {name: 'noah'}});

  return (
    <form>
      <CustomTextField
        label="name"
        type="text"
        id="name"
        name="name"
        control={control}
      />
    </form>
  );
};

create adaptor

register 関数の返り値に処理を差し込んでinputRefを返すようにする方法です. 書き方が少し煩雑にはなるのですが, register関数の処理を自前で定義した関数で囲みます. そしてその関数でrefをinputRefにします.

const registerMui = (res: UseFormRegisterReturn) => ({
  inputRef: res.ref,
  onChange: res.onChange,
  onBlur: res.onBlur,
  name: res.name,
})

呼び出し側ではこのようにregister関数の実行をregisterMuiで囲むようにします. 同様にuseForm自体をカスタムフックに処理を入れる方法も良さそうかなぁと思っています.

const Greeting = () => {
  const {register} = uesForm();
 	return (
  	<TextField label="first_name" type="text"
   	  {...registerMui(register('first_name'))}
		/>
  ) 
}

digression

register関数の返り値をカスタマイズするRFCが出ており議論されています. なので近い将来に今まで紹介したラッパー的なものなしで扱いやすくなるかもしれません.

[RFC] add @hookform/adapter to customize register return value #5250

in the end

ライブラリを組み合わせるとどうしても固有のよくない振る舞いをしてしまうことがあり, それの回避策を今回は考えていました.
React Hook Formはコミュニティと開発が活発で常にお世話になっているので感謝しかありません.

references