Open26
React Hook Formの公式ドキュメントとコードを読む
DXの設計思想
- validation
- HTMLの組み込みvalidationにalignする
- ほかのschema validationライブラリと容易に連携できる
- TS support
- register関数に渡すフィールド名やerrorsオブジェクトへのアクセスがformの型と整合していない場合にtype errorになる。
UXの設計思想
- アクセシビリティ(focus management)
- フォームでエラーが生じた際に、エラーメッセージを表示するのではなく、「なにをすればエラーが直るのか」にフォーカスさせてあげた方がUXが良い
- React Hook Formでは、submit時にエラーが起こったら、自動的にそのフォームにフォーカスが当たるようになっている
- 動的にフォームを追加するような複雑な場合でも、追加したフィールドにフォーカスが当たるようになっている
- 仕組みとしては、React hook formは、register関数でref, onChange, onBlurをinputに渡しており、input要素のrefを保持するreference storeと、ユーザーの入力を監視するvalue storeを持っている。保持したrefでDOMを操作することにより、focusを自由に当てることができる。
- フォームでエラーが生じた際に、エラーメッセージを表示するのではなく、「なにをすればエラーが直るのか」にフォーカスさせてあげた方がUXが良い
- パフォーマンス
- 余分なre-renderとcomputationを最小限にする。Reactが全てのformの状態を管理する必要はない。
- ブラウザは、built-inのアクセシビリティ、パフォーマンス、i18n機能を持っている。
- Reactが必要になる場面は、下記のような複雑なフォームの管理
- dirty, touched, validなどの多数の状態を持つフォーム
- 複雑なvalidation (e.g., 非同期, フィールド間の相互依存)が必要なフォーム
- Reactが全てのstateを管理する場合、ユーザー入力ごとに、最悪フォーム全体にre-renderがかかってしまい、メモ化などの必要性が出てくる
- React hook formのアプローチ
- Reactのstateでフォームの状態をcontrolしない。
- その代わり、フォームの状態の更新を、「その更新をsubscribeしている個々のコンポーネント」にだけreportする。validationも、必要な時だけ計算する。
- バリデーション戦略
- 「ユーザーがフォームに触れたときのみバリデーション」みたいなタイミング制御を、
useForm
の引数のmode
でフォーム全体に簡単に設定できる。 - submitボタンを「押す前」と「押した後」で異なるバリデーションタイミングを指定できる。例えば、基本的にはsubmitボタンを押した時点でバリデーションするが、そのあとは入力するたびにバリデーションする、など
- UX上、エラーメッセージ表示のタイミングを実際の検知よりすこし遅らせた方がいい場合がある。こういうのも指定できる。
- 「ユーザーがフォームに触れたときのみバリデーション」みたいなタイミング制御を、
useFormの引数
-
mode
- formがsubmitされる前のバリデーション方針
-
reValidateMode
- formがsubmitされた後のバリデーション方針
-
defaultValues
- フォームのデフォルト値。
defaultValues: async () => fetch('/api-endpoint');
みたいに非同期関数を渡すこともできる。 - フォームのフィールドごとに
defaultValue
を設定することもできるが、このdefaultValues
を使うことが推奨される -
defaultValues
はキャッシュされており、ここに後から違う値を渡しても無視される。リセットするには、useForm
の返り値のreset
APIを使う。 -
defaultValues
にundefinedを渡すことは避ける。(controlledコンポーネントのデフォルトstateと衝突する可能性があるため)
- フォームのデフォルト値。
-
values
- ここにデータを渡すと、いつでもformの値を更新できる。
- 例えば下記のように書くと、最初はdefaultValuesが入り、その後APIレスポンスがあったらそれでフォームの値を書き換えることができる
function App() {
const values = useFetch('/api');
useForm({
defaultValues: {
firstName: '',
lastName: '',
},
values, // will get updated once values returns
})
}
-
resetOptions
-
reset
APIが使われたときの挙動を指定。 -
values
やdefaultValues
の更新時ににも内部的にreset
APIが使われることに注意。
-
-
criteriaMode
- 一つのフィールドから複数のエラーが発生した場合の挙動を指定
-
delayError
- エラー発生時に、ユーザーにそれを伝えるのをどれだけ遅らせるか
-
shouldUnregister
- フォームのinput要素が取り除かれた時に、そのinputの値をReact hook formで保持するかどうか。デフォルトでは保持。(=
shouldUnregister
がfalse
) -
false
の場合の注意点- 取り除かれた要素の値は、バリデーションされない
-
true
の場合の注意点-
defaultValues
がフォームのsubmission時のデータにマージされない - フィールドの値は、input要素それ自体に保持される
- hidden input的なことをやりたかったら、input要素に
hidden
属性つけて要素自体は残しておく
-
- フォームのinput要素が取り除かれた時に、そのinputの値をReact hook formで保持するかどうか。デフォルトでは保持。(=
-
resolver
- Zodなどの外部バリデーションライブラリを使うときはこれ。
register
-
返り値として、
onChange
,onBlur
,ref
,name
があり、これをinput要素に渡す(慣用的にspread構文が使われる) -
引数(気になったものだけ)
-
validate
- バリデーションを実行するcallbackを単体で渡すこともできるし、下記のように複数のバリデーションルールに対応する複数のcallbackをオブジェクトの形で渡すこともできる
-
<input
{...register("test1", {
validate: {
positive: v => parseInt(v) > 0 || 'should be greater than 0',
lessThanTen: v => parseInt(v) < 10 || 'should be lower than 10',
validateNumber: (_: number, formValues: FormValues) => {
return formValues.number1 + formValues.number2 === 3 || 'Check sum number';
},
// you can do asynchronous validation as well
checkUrl: async () => await fetch() || 'error message', // JS only: <p>error message</p> TS only support string
messages: v => !v && ['test', 'test2']
}
})}
/>
-
valueAsNumber
- フォームの値がnumberになる。(エラー時にはNaN)
-
valueAsDate
- 同様
-
setValueAs
- valueAsNumberとかを一般化したもの。下記のように値を変換する関数を渡すことができる。
- text inputでのみ使用可能
<input
type="number"
{...register("test", {
setValueAs: v => parseInt(v),
})}
/>
-
onChange
,onBlur
- フォームの値の変更時やフォーカス外れた時に、カスタムの処理を入れ込める
-
value
- フォームの値を明示的に指定できる。この値は、
useEffect
などで指定のタイミングにのみ渡すべき。そうしないと、re-renderのたびに値がここで指定されたものに変わってしまう。
- フォームの値を明示的に指定できる。この値は、
-
deps
- 下記のように指定しておくと、inputAやinputBが変化した時にバリデーションがトリガされるようになる。
<input
{...register("test", {
deps: ['inputA', 'inputB'],
})}
/>
- 注意点/tips
- 入力要素が配列になっているときは、
register('test.0.firstName')
などと書く。 - custom register:
register
の返り値は必ずしもinput要素とかに入れなくても良い。registerだけ呼んでおいて、あとはsetValue
などを使って値を手動で更新していくこともできる - カスタムコンポーネントで、ref propの名前が inputRefとかなっていた場合、
register
の返り値のref
をそこに入れればOK
- 入力要素が配列になっているときは、
formState
-
現在のフォームの状態を取得できる。
-
気になったプロパティ
-
isDirty
、dirtyFields
- ユーザーが何かデフォルトから変更を加えると
dirty
な状態になる。それを取得する
- ユーザーが何かデフォルトから変更を加えると
-
isSubmitted
、isSubmitting
、isSubmitSuccessful
、submitCount
- submitの状態を取得できる
-
isValid
- エラーが全くない時にtrueになる
-
errors
- 全てのエラーを取得
-
-
注意点/tips
- もし
formState
がコンポーネントで使われてなかったら、余分なロジック(=re-render?)を実行しないようになっている。- 実装的には、React hook formの内部で保持している
formState
に変化があった際、shouldRenderFormState
という関数で、コンポーネントのre-renderを行う(=useState
で作ったstateに保持しているformState
を更新する)かどうかを判断するが、その際、control._proxyFormState
の値のどれかにall
が含まれているかをチェックしており、all
が含まれている場合はre-renderする、という実装になっている。 - https://github.com/react-hook-form/react-hook-form/blob/master/src/useForm.ts#L91
- https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/shouldRenderFormState.ts#L25
-
useForm
の返り値のformState
は下記で作られているが、ここでは、formState
の各プロパティにカスタムのgetter関数が定義されており、この中で、control._proxyFormState
の当該プロパティの値をall
にする処理がある。なので、コンポーネント側でformState
のどれかにアクセスすると、その後はformState
の変化でre-renderがトリガされるようになる。 - https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/getProxyFormState.ts#L20
- 実装的には、React hook formの内部で保持している
- また、
formState
の更新はバッチで行われる。useEffect
とかのdependencyにformState
のプロパティの何かを入れたいときは、formState
全体を入れること。
- もし
watch
-
指定されたフィールドに変化があったときに、それをトリガにre-renderして、そのフィールドの値を返してくれる。
-
下記のように使い方にバリエーションがある
-
watch('inputName')
のように単体のフィールド名を指定 -
watch(['inputName1'])
のように複数のフィールド名を指定 -
watch()
のように何も指定しない -> 全てのフィールドをwatchする -
watch((data, { name, type }) => console.log(data, name, type))
のようにcallback関数を与える
-
-
注意点/tips
-
defaultValues
を指定しておかないと、最初のrender時のwatch
の返り値がundefinedになってしまう -
watch
は、フォームのルートコンポーネント(=useForm
が書かれているコンポーネント)全体のre-renderを引き起こす。これがパフォーマンス上問題になる場合は、useWatch
などを使うと良い -
watch
の返り値を使って入力値の更新を検出したい場合は、外部のカスタムhookが必要になるかも
-
-
内部実装
-
watch
関数は、generateWatchOutput
という関数によって作られ、useForm
の返り値に入れられる。 - https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/createFormControl.ts#L491
-
watch
関数が何かしらのフィールド名を引数として呼ばれた場合、_names.watch
という場所にそのフィールド名が格納される。 - https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/generateWatchOutput.ts#L13
- 入力フィールドの値が何かしら変化した際(=ユーザーの入力やsetValueが呼ばれた際)に、
isWatched
という関数が呼ばれるが、そこで_names.watch
に当該フィールド名があるかをチェックしており、もしあればisWatched
がtrue
となる。 - https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/createFormControl.ts#L682
- https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/isWatched.ts
- そうすると、
_subjects.state.next
関数が呼ばれる。 - https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/createFormControl.ts#L714
- https://github.com/react-hook-form/react-hook-form/blob/master/src/logic/createFormControl.ts#L718
-
_subjects.state.next
関数の実体は下記。ここでshouldRenderFormState
がtrue
なら、re-renderがトリガされる。 - https://github.com/react-hook-form/react-hook-form/blob/master/src/useForm.ts#LL87C5-L87C5
-
reset
- フォームのフィールドの値(values)を与え、フォームの全ての状態、ref、subscriptionをリセットする。
- valuesには全てのフィールドの値を与えることが推奨される
resetField
-
reset
のフィールド名指定版
setError
- 手動でエラー設定できる
-
handleSubmit
内で非同期でAPIエンドポイントからバリデーションエラーを受け取る時などに便利 - フォームのフィールドに
register
などでバリデーションルールが設定されている場合、setError
で設定したエラーは、register
側のルールがpassしたら消える - フォームのフィールドにないエラーをsetすることもできる。この場合、
clearErrors
を呼ばないとエラーは消えない
setValue
-
特定のフォームフィールドの値をsetできる。オプションで、set時にバリデーションを行うかなどを指定できる
-
注意点/tips
- 値のsetによりre-renderが起こる条件: エラーが起こる/起こらなくなるとき、dirty/touchedなどのstateが変わるとき。(ただ、これも
formState
の各プロパティをコンポーネントで使ってたら、っていう話だと思う) -
setValue('yourDetails', { firstName: 'value' });
より、setValue('yourDetails.firstName', 'value');
の方がパフォーマンスが良い
- 値のsetによりre-renderが起こる条件: エラーが起こる/起こらなくなるとき、dirty/touchedなどのstateが変わるとき。(ただ、これも
getValues
-
フォームの値を取得する。
watch
と違い、getValues
で特定のフィールドを読んでいたとしても、そのフィールドの値が変わったときにre-renderが起こらない -
引数なしだとフォームの全データ取得、フィールド名(or フィールド名のarray)を入れると特定のフィールドだけ取得
-
注意点/tips
- disabledなフィールドだとundefinedが返される
- 初回render時は
defaultValues
が返される
getFieldState
-
formState
と似てるが、個々のフィールドのerrorなどをとってこれる。 -
フィールドがネストしてる場合にerrorとかを型安全に取ってくるのに有効
-
注意点/tips
- 使うには、
formState
の返り値のerrorsとかをコンポーネントで読んでいることが必要(=これにより、エラーとかのupdate時にre-renderが走ることが必要)。 下記みたいな感じ。
const { formState: { errors } } = useForm() // errors are subscribed and reactive to state update getFieldState('firstName') // return updated field error state
- もしそうでなければ、下記のようにgetFieldStateの第二引数にformStateを入れないといけない。
const methods = useForm(); // not subscribed to any formState const { error } = getFieldState('firstName', methods.formState) // It is subscribed now and reactive to error state updated
- なので、
formState
をそのまま使うのと比べてre-renderが抑制されるとかではない。
- 使うには、
trigger
-
手動で、フィールドのバリデーションをトリガする。
-
あるフィールドのバリデーションが、他のフィールドのものに依存してるときとかにも有効
-
注意点/tips
- 引数なしや、引数を文字列のarrayで渡して、複数のフィールドのバリデーションをtriggerすると、formState全体をre-renderしてしまう。
useForm
以外のhookも見ていく
useController
- controlled componentをReact Hook Formで扱う時に、Controllerのrender propを使って、対象コンポーネントをラップするのがある。
-
useController
を使うと、下記のようにrender prop不要の、違った書き方ができる。
import { TextField } from "@material-ui/core";
import { useController, useForm } from "react-hook-form";
function Input({ control, name }) {
const {
field,
fieldState: { invalid, isTouched, isDirty },
formState: { touchedFields, dirtyFields }
} = useController({
name,
control,
rules: { required: true },
});
return (
<TextField
onChange={field.onChange} // send value to hook form
onBlur={field.onBlur} // notify when input is touched/blur
value={field.value} // input value
name={field.name} // send down the input name
inputRef={field.ref} // send input ref, so we can focus on input when error appear
/>
);
}
useFormContext
- 下記のように、
FormProvider
と組み合わせて使うことで、useForm
の返り値を、子コンポーネントで取得することができる。
const methods = useForm()
<FormProvider {...methods} /> // all the useForm return props
const methods = useFormContext() // retrieve those props
useWatch
-
watch
と似ているが、カスタムhookレベルでre-renderを分離することができるため、パフォーマンスが上がる可能性がある - 例えば、下記のような場合に、
firstName
フィールドに更新があった際、re-renderはChild
コンポーネントのみ走る。watch
を使った場合は、useForm
を呼んでる場所である親コンポーネントまでre-renderが走ってしまう。
import React from "react";
import { useForm, useWatch } from "react-hook-form";
function Child({ control }) {
const firstName = useWatch({
control,
name: "firstName",
});
return <p>Watch: {firstName}</p>;
}
function App() {
const { register, control } = useForm({
firstName: "test"
});
return (
<form>
<input {...register("firstName")} />
<Child control={control} />
</form>
);
}
useFormState
-
watch
とuseWatch
の関係同様、formState
の中の値(errorsなど)を子コンポーネントで使いたい時に、親コンポーネントのre-renderを抑えることができる。
import React from "react";
import { useForm, useFormState } from "react-hook-form";
function Child({ control }) {
const { dirtyFields } = useFormState({
control
});
return dirtyFields.firstName ? <p>Field is dirty.</p> : <></>;
}
export default function App() {
const { register, handleSubmit, control } = useForm({
defaultValues: {
firstName: "firstName"
}
});
return (
<form>
<input {...register("firstName")} placeholder="First Name" />
<Child control={control} />
</form>
);
}
Accessibility
- ARIAを使って、下記のようにフォームのアクセシビリティを改善できる。
import { useForm } from "react-hook-form";
export default function App() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="name">Name</label>
{/* use aria-invalid to indicate field contain error */}
<input
id="name"
aria-invalid={errors.name ? "true" : "false"}
{...register('name', { required: true, maxLength: 30 })}
/>
{/* use role="alert" to announce the error message */}
{errors.name && errors.name.type === "required" && (
<span role="alert">This is required</span>
)}
{errors.name && errors.name.type === "maxLength" && (
<span role="alert">Max length exceeded</span>
)}
<input type="submit" />
</form>
);
}
Wizard Form / Funnel
- 複数ページにまたがるようなフォームは、各ページごとに
useForm
を使い、handleSubmit
の中で、何かストア的なもの(e.g., redux)にフォームのデータを入れていくようにすると良い
FormProvider Performance
-
FormProvider
はReactのContext APIを使っているため、React Hook Formが、FormProvider
配下のどこかの子コンポーネントでre-renderを引き起こした場合、FormProvider
配下のすべての子コンポーネントがre-renderされてしまう問題がある。 - これを解消するために、コンポーネントを下記のようにmemo化する方法がある。この例では、
NestedInput
をmemo化しており、さらにisDirty
が変化した時のみコンポーネントを再レンダリングするようにしている
import React, { memo } from "react";
import { useForm, FormProvider, useFormContext } from "react-hook-form";
// we can use React.memo to prevent re-render except isDirty state changed
const NestedInput = memo(
({ register, formState: { isDirty } }) => (
<div>
<input {...register("test")} />
{isDirty && <p>This field is dirty</p>}
</div>
),
(prevProps, nextProps) =>
prevProps.formState.isDirty === nextProps.formState.isDirty
);
export const NestedInputContainer = ({ children }) => {
const methods = useFormContext();
return <NestedInput {...methods} />;
};
export default function App() {
const methods = useForm();
const onSubmit = data => console.log(data);
console.log(methods.formState.isDirty); // make sure formState is read before render to enable the Proxy
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<NestedInputContainer />
<input type="submit" />
</form>
</FormProvider>
);
}
form builderが地味に便利。ぽちぽちするだけでフォームを生成してくれる