Open26

React Hook Formの公式ドキュメントとコードを読む

nakaakistnakaakist

DXの設計思想

  • validation
    • HTMLの組み込みvalidationにalignする
    • ほかのschema validationライブラリと容易に連携できる
  • TS support
    • register関数に渡すフィールド名やerrorsオブジェクトへのアクセスがformの型と整合していない場合にtype errorになる。

nakaakistnakaakist

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を自由に当てることができる。

  • パフォーマンス
    • 余分な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上、エラーメッセージ表示のタイミングを実際の検知よりすこし遅らせた方がいい場合がある。こういうのも指定できる。
nakaakistnakaakist

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が使われたときの挙動を指定。
    • valuesdefaultValuesの更新時ににも内部的にreset APIが使われることに注意。
  • criteriaMode
    • 一つのフィールドから複数のエラーが発生した場合の挙動を指定
  • delayError
    • エラー発生時に、ユーザーにそれを伝えるのをどれだけ遅らせるか
  • shouldUnregister
    • フォームのinput要素が取り除かれた時に、そのinputの値をReact hook formで保持するかどうか。デフォルトでは保持。(=shouldUnregisterfalse)
    • falseの場合の注意点
      • 取り除かれた要素の値は、バリデーションされない
    • trueの場合の注意点
      • defaultValuesがフォームのsubmission時のデータにマージされない
      • フィールドの値は、input要素それ自体に保持される
      • hidden input的なことをやりたかったら、input要素にhidden属性つけて要素自体は残しておく
  • resolver
    • Zodなどの外部バリデーションライブラリを使うときはこれ。
nakaakistnakaakist

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
nakaakistnakaakist

formState

  • 現在のフォームの状態を取得できる。

  • 気になったプロパティ

    • isDirtydirtyFields
      • ユーザーが何かデフォルトから変更を加えるとdirtyな状態になる。それを取得する
    • isSubmittedisSubmittingisSubmitSuccessfulsubmitCount
      • submitの状態を取得できる
    • isValid
      • エラーが全くない時にtrueになる
    • errors
      • 全てのエラーを取得
  • 注意点/tips

    • もしformStateがコンポーネントで使われてなかったら、余分なロジック(=re-render?)を実行しないようになっている。
    • また、formStateの更新はバッチで行われる。useEffectとかのdependencyにformStateのプロパティの何かを入れたいときは、formState全体を入れること。
nakaakistnakaakist

watch

nakaakistnakaakist

reset

  • フォームのフィールドの値(values)を与え、フォームの全ての状態、ref、subscriptionをリセットする。
  • valuesには全てのフィールドの値を与えることが推奨される

resetField

  • resetのフィールド名指定版
nakaakistnakaakist

setError

  • 手動でエラー設定できる
  • handleSubmit内で非同期でAPIエンドポイントからバリデーションエラーを受け取る時などに便利
  • フォームのフィールドにregisterなどでバリデーションルールが設定されている場合、setErrorで設定したエラーは、register側のルールがpassしたら消える
  • フォームのフィールドにないエラーをsetすることもできる。この場合、clearErrorsを呼ばないとエラーは消えない
nakaakistnakaakist

setValue

  • 特定のフォームフィールドの値をsetできる。オプションで、set時にバリデーションを行うかなどを指定できる

  • 注意点/tips

    • 値のsetによりre-renderが起こる条件: エラーが起こる/起こらなくなるとき、dirty/touchedなどのstateが変わるとき。(ただ、これもformStateの各プロパティをコンポーネントで使ってたら、っていう話だと思う)
    • setValue('yourDetails', { firstName: 'value' });より、setValue('yourDetails.firstName', 'value');の方がパフォーマンスが良い
nakaakistnakaakist

getValues

  • フォームの値を取得する。watchと違い、getValuesで特定のフィールドを読んでいたとしても、そのフィールドの値が変わったときにre-renderが起こらない

  • 引数なしだとフォームの全データ取得、フィールド名(or フィールド名のarray)を入れると特定のフィールドだけ取得

  • 注意点/tips

    • disabledなフィールドだとundefinedが返される
    • 初回render時はdefaultValuesが返される
nakaakistnakaakist

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が抑制されるとかではない。
nakaakistnakaakist

trigger

  • 手動で、フィールドのバリデーションをトリガする。

  • あるフィールドのバリデーションが、他のフィールドのものに依存してるときとかにも有効

  • 注意点/tips

    • 引数なしや、引数を文字列のarrayで渡して、複数のフィールドのバリデーションをtriggerすると、formState全体をre-renderしてしまう。
nakaakistnakaakist

control

  • 注意点/tips
    • controlの中身は直接いじらない!基本的にReact hook form内部で使う
nakaakistnakaakist

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
    />
  );
}
nakaakistnakaakist

useFormContext

  • 下記のように、FormProviderと組み合わせて使うことで、useFormの返り値を、子コンポーネントで取得することができる。
const methods = useForm()
      
<FormProvider {...methods} /> // all the useForm return props
      
const methods = useFormContext() // retrieve those props
nakaakistnakaakist

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>
  );
}
nakaakistnakaakist

useFormState

  • watchuseWatchの関係同様、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>
  );
}

nakaakistnakaakist

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>
  );
}
nakaakistnakaakist

Wizard Form / Funnel

  • 複数ページにまたがるようなフォームは、各ページごとにuseFormを使い、handleSubmitの中で、何かストア的なもの(e.g., redux)にフォームのデータを入れていくようにすると良い
nakaakistnakaakist

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>
  );
}