input[type="file"]をreact-hook-formでいい感じに使えるようにする


Reactでフォームを扱う際のメジャーなライブラリの一つとしてreact-hook-formがあります。
TypeScriptを利用した環境でも型推論がそれなりに適切に行われ、機能的にも主要なユースケースでは問題ないレベルで充足していると考え、実際のプロジェクトでも多くの場面で利用しています。

input[type="file"]をreact-hook-formで利用した場合の挙動と問題点

そんなreact-hook-formですが、input[type="file"]と組み合わせて使用すると、watch()useWatch()が、他のinput要素などのように変更した際にwatchしている値が更新されないという挙動をすることに気付きました。
正確には、未選択の状態でファイルを選択した際はwatchしている値が更新されるが、それ以降、別のファイルを選択しなおしても更新されないという挙動となります。
もちろん、input[type="file"]には再選択したファイルが適切に保持されており、フォームがsubmitされた場合は期待されるファイルがhandlerに渡されます。

ファイルの選択はユーザにとって、インタラクションが多く負荷のかかるアクションです。そのため、submit時ではなく、ユーザがファイルを選択してすぐに、サイズなどのバリデーションを行い、必要であれば直ちに再選択を促すという挙動を実現したい、という要望は多いでしょう。
react-hook-formにはonChangeという、フォームの値が更新された時点でバリデーションを実行するというモードが存在しますが、これは常に再レンダリングを行うことによって実現されるため、パフォーマンスの観点からあまり推奨されません。
onBlurモードではblurイベントが発生したタイミングでバリデーションが行われますが、ファイル選択コンポーネントの場合、input要素自体を非表示にした上でUIがカスタマイズされるケースが多く、適切なタイミングでblurイベントが発生しない場合が存在します。
そのため、最も確実な方法はuseWatch()を用いてinput[type="file"]の変更をuseEffectで監視し、変更されたタイミングで個別にバリデーションを実行するという実装になるのですが、そこで問題になるのが前述の挙動です。

なお、watch()を用いても同様の挙動となるのですが、useForm()が返すwatch()による値の更新は再レンダリングによって実行されます。ドキュメントにもあるようにパフォーマンスの問題が発生する可能性があるので、私はuseWatchを用いることを勧めます。

react-hook-formのuseWatch()はどのように実装されているか

useWatch()の実装はこの箇所です。

https://github.com/react-hook-form/react-hook-form/blob/v7.29.0/src/useWatch.ts#L127-L207

概要としては、useState()で値を保持し、useSubscribe()を用いて、stateを更新するコールバックをuseForm()が返すcontrol_subjects.watchに登録するというものとなります。

https://github.com/react-hook-form/react-hook-form/blob/v7.29.0/src/logic/createFormControl.ts#L141

useSubscribe()はreact-hook-formが内部で利用しているSubjectを購読するためのカスタムフックで、Subject自体はこの箇所で定義されています。

https://github.com/react-hook-form/react-hook-form/blob/v7.29.0/src/utils/createSubject.ts
いわゆるObserverパターンの実装で興味深いのですが、今回の挙動に直接関係するわけではないため、簡単な紹介にとどめます。

useWatch()で登録されるコールバックを見てみましょう。

const callback = React.useCallback(
  (formState) => {
    if (
      shouldSubscribeByName(
        _name.current as InternalFieldName,
        formState.name,
        exact,
      )
    ) {
      const fieldValues = generateWatchOutput(
        _name.current as InternalFieldName | InternalFieldName[],
        control._names,
        formState.values || control._formValues,
      );

      updateValue(
        isUndefined(_name.current) ||
          (isObject(fieldValues) && !objectHasFunction(fieldValues))
          ? { ...fieldValues }
          : Array.isArray(fieldValues)
          ? [...fieldValues]
          : isUndefined(fieldValues)
          ? defaultValue
          : fieldValues,
      );
    }
  },
  [control, exact, defaultValue],
);

generateWatchOutput()で取得したfieldValues用いてstateを更新する。fieldValuesがオブジェクトか配列の場合はスプレッド構文を用いて新たな参照先として更新、それ以外の場合はundefinedではなければそのままfieldValuesを用いるという実装になっています。

generateWatchOutput()は以下で定義されている関数で、大まかな挙動としては指定したフィールド名のフォームの値を返すものです。

https://github.com/react-hook-form/react-hook-form/blob/v7.29.0/src/logic/generateWatchOutput.ts

この状況では、コールバックである引数のformState.values || control._formValuesからuseWatch()で渡されたnameに対応する値を返します。
フォームの登録された要素のchangeイベントで実行されるonChange()の内部ではフィールド名とイベントタイプを引数としてcontrol._subjects.watchが実行されるため、この場合は常にcontrol._formValuesが用いられることとなります。

https://github.com/react-hook-form/react-hook-form/blob/v7.29.0/src/logic/createFormControl.ts#L694-L698

onChange()における_formValuesの更新処理はこの箇所で行われます。

https://github.com/react-hook-form/react-hook-form/blob/v7.29.0/src/logic/createFormControl.ts#L655-L677
引数として渡されたeventtypeプロパティが存在する場合はgetFieldValue()を、そうでない場合はgetEventValue()を用いて値を取り出し、_formValuesを更新します。
input[type="files"]にregisterを用いて登録した場合は、ChangeEventFocusEventeventが引数として渡されて実行されるため、getFieldValue()が実行されます。

getFieldValue()は以下で定義されます。

https://github.com/react-hook-form/react-hook-form/blob/20cdf8567ce14ae0d2784cc39e75af873caa9c8f/src/logic/getFieldValue.ts
関数は自体は複雑ではなく、次のようになります。
export default function getFieldValue(_f: Field['_f']) {
  const ref = _f.ref;

  if (_f.refs ? _f.refs.every((ref) => ref.disabled) : ref.disabled) {
    return;
  }

  if (isFileInput(ref)) {
    return ref.files;
  }

  if (isRadioInput(ref)) {
    return getRadioValue(_f.refs).value;
  }

  if (isMultipleSelect(ref)) {
    return [...ref.selectedOptions].map(({ value }) => value);
  }

  if (isCheckBox(ref)) {
    return getCheckboxValue(_f.refs).value;
  }

  return getFieldValueAs(isUndefined(ref.value) ? _f.ref.value : ref.value, _f);
}

react-hook-formはregister()で登録されたinput要素などのrefを保持しています。
input[type="file"]が登録された場合、isFileInput(ref)trueになるためinput.filesを返すことがわかります。

useWatch()の仕組みを順に追ってみると、input[type="file"]の場合も適切に考慮され一見問題がないように見えます。

では今度は、useWatch()の値を監視するuseEffect()についてみてみましょう。
Reactのドキュメントでは、「useEffect の第 2 引数として、この副作用が依存している値の配列を渡します」、「データの購読は props.source が変更された場合にのみ再作成されるようになります」とあります。

https://ja.reactjs.org/docs/hooks-reference.html#conditionally-firing-an-effect
Reactがhooksで渡された値が変更されているかを判定する処理は以下で定義されています。
https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberHooks.new.js#L299-L344
https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/shared/objectIs.js
is()関数はObject.is()のPolyfillであり、詳細な仕様は以下となります。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/is
重要なポイントは「どちらも同じオブジェクト」の場合は同一値として判定される、内部のプロパティが変更されたとしても、変更として検知されないということです。
Reactを学ぶ際によく言われる、stateとして保持しているオブジェクトのプロパティを直接変更してはならないといったポイントはこの挙動によるものです。

さて、これまでのreact-hook-formのuseWatch()の説明で、次のように述べました。

fieldValuesがオブジェクトか配列の場合はスプレッド構文を用いて新たな参照先として更新、それ以外の場合はundefinedではなければそのままfieldValuesを用いるという実装になっています。

input[type="file"]が登録された場合、isFileInput(ref)trueになるためinput.filesを返すことがわかります。

input[type="file"]の場合、getFieldValue()で取得される値はinput.filesでその型はFileListです。そのため、オブジェクトでも配列でもないため、ipnut.filesの参照がそのままfiledValuesとして用いられます。
そして、input[type="file"]のfilesはその要素が生成された後は、破棄されるまで常に同じオブジェクトを再利用します。
そのため、input[type="file"]に対してuseWatch()を用いた場合、戻り値は常に同じオブジェクトを参照しており、react-hook-formが適切に動作しているがuseEffect()において変更として扱われず、結果としてファイルを再選択しても更新されないという状況が発生することとなります。

input[type="file"]も他の要素と同様の挙動とするには

input.filesが常に同じオブジェクトを再利用しているという挙動が原因であるため、それを回避するためのコンポーネントを用意します。

export type InputFileProps = Omit<
  DetailedHTMLProps<
    InputHTMLAttributes<HTMLInputElement>,
    HTMLInputElement
  >,
  'type' | 'value' | 'defaultValue'
>

export const InputFile = forwardRef<HTMLInputElement, InputFileProps>(
  ({ onChange, ...props }, ref) => {
    const inputRef = useRef<HTMLInputElement>(null)

    // 通常は同じfilesへの参照を保持し続けreactのライフサイクルで検知できないため、新たにFileListを生成する
    const onInputChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
      (event) => {
        event.target.files = createFileList(event.target.files)
        onChange && onChange(event)
      },
      [onChange]
    )

    return (
      <input
        {...props}
        type="file"
        onChange={onInputChange}
        ref={mergeRefs([ref, inputRef])}
      />
    )
  }
)

// FileListは直接生成することができないため、DataTransferを経由する
export const createFileList: (fileList?: FileList | null) => FileList = (
  fileList
) => {
  const dataTransfer = new DataTransfer()

  if (!fileList) {
    return dataTransfer.files
  }

  for (const file of fileList) {
    dataTransfer.items.add(file)
  }

  return dataTransfer.files
}

/**
 * 複数のrefを結合して返す
 */
export function mergeRefs<T>(
  refs: Array<MutableRefObject<T> | LegacyRef<T>>
): RefCallback<T> {
  return (value) => {
    refs.forEach((ref) => {
      if (typeof ref === 'function') {
        ref(value)
      } else if (ref != null) {
        ;(ref as MutableRefObject<T | null>).current = value
      }
    })
  }
}

changeイベントのハンドラでinput.filesを新たに生成したFileListオブジェクトで置き換えることによって、useEffect()でその変更を検知することが可能となります。
FileListオブジェクトは直接生成することができないので、Drag and Dropを実装する際に用いられるDataTransferを利用しています。

https://developer.mozilla.org/ja/docs/Web/API/DataTransfer
以前はinput.filesは読み取りのみでしたが、現在では設定が可能となっています。
https://developer.mozilla.org/ja/docs/Web/HTML/Element/input/file#getting_information_on_selected_files

まとめ

当初は独自のコンポーネントとreact-hook-formの組み合わせでファイル再選択時の値の変更を検知できない事象から調査を始めたのですが、input[type="file"]を利用した場合でも再現したため、react-hook-formの実装の調査を行いました。
結果的にはinput[type="file"]のinput.filesが常に同じオブジェクトを参照し続けているという、Reactを利用する際の初歩的なポイントが原因になって発生していた事象でした。

しかし、useRef()を利用してDOMを直接操作する際に起こりうる事象ではあること、react-hook-formといったライブラリを利用していても発生する可能性があることから、今回の記事をまとめました。
同じような問題に遭遇した方の参考になれば幸いです。