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
を用いることを勧めます。
useWatch()
はどのように実装されているか
react-hook-formのuseWatch()
の実装はこの箇所です。
概要としては、useState()
で値を保持し、useSubscribe()
を用いて、stateを更新するコールバックをuseForm()
が返すcontrol
の_subjects.watch
に登録するというものとなります。
useSubscribe()
はreact-hook-formが内部で利用しているSubject
を購読するためのカスタムフックで、Subject
自体はこの箇所で定義されています。
いわゆる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()
は以下で定義されている関数で、大まかな挙動としては指定したフィールド名のフォームの値を返すものです。
この状況では、コールバックである引数のformState.values || control._formValues
からuseWatch()
で渡されたname
に対応する値を返します。
フォームの登録された要素のchangeイベントで実行されるonChange()
の内部ではフィールド名とイベントタイプを引数としてcontrol._subjects.watch
が実行されるため、この場合は常にcontrol._formValues
が用いられることとなります。
onChange()
における_formValues
の更新処理はこの箇所で行われます。
引数として渡されたevent
にtype
プロパティが存在する場合はgetFieldValue()
を、そうでない場合はgetEventValue()
を用いて値を取り出し、_formValues
を更新します。
input[type="files"]にregister
を用いて登録した場合は、ChangeEvent
やFocusEvent
のevent
が引数として渡されて実行されるため、getFieldValue()
が実行されます。
getFieldValue()
は以下で定義されます。
関数は自体は複雑ではなく、次のようになります。
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 が変更された場合にのみ再作成されるようになります」とあります。
Reactがhooksで渡された値が変更されているかを判定する処理は以下で定義されています。
is()
関数はObject.is()
のPolyfillであり、詳細な仕様は以下となります。
重要なポイントは「どちらも同じオブジェクト」の場合は同一値として判定される、内部のプロパティが変更されたとしても、変更として検知されないということです。
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
を利用しています。
以前はinput.files
は読み取りのみでしたが、現在では設定が可能となっています。
まとめ
当初は独自のコンポーネントとreact-hook-formの組み合わせでファイル再選択時の値の変更を検知できない事象から調査を始めたのですが、input[type="file"]を利用した場合でも再現したため、react-hook-formの実装の調査を行いました。
結果的にはinput[type="file"]のinput.files
が常に同じオブジェクトを参照し続けているという、Reactを利用する際の初歩的なポイントが原因になって発生していた事象でした。
しかし、useRef()
を利用してDOMを直接操作する際に起こりうる事象ではあること、react-hook-formといったライブラリを利用していても発生する可能性があることから、今回の記事をまとめました。
同じような問題に遭遇した方の参考になれば幸いです。
Discussion