React Hook FormとTypeScriptと状態管理について思うこと

仕事でReact Hook Formのv6を使用しているのですが、そろそろ執筆時点で最新のv7に更新したいなと思っておりReact Hook Formについて学習し直したのであれこれ思うことについて書いていきます。サンプルコードとかもそのうち載せていい記事にしたいなと思うんですが、一旦はこの数日間考えてみたことを整理して記録するために記事にします。

書いてるうちに長くなってきたので先に個人的にうまくReact Hook Formと付き合うための結論を書いときます。

- ボタン操作だけで済む画面なら`useState`で管理する。
- ボタンだけでなく、キーボードやフリック入力をしないといけない入力用のコンポーネントならボタンの状態も含めて全てReact Hook Formで管理する。
- 複数画面にまたがるフォームの場合はページごとに確認ボタン(**次へ**ボタンかもしれない)を設置して、確認ボタンが押された時にフォームの入力内容をReduxに送信する。全てのページの入力が済んだら全ての入力内容をまとめてAPIで送信する。
  - 戻るボタンが押された場合はReduxから前の画面のフォームで入力した内容を取得して、デフォルト値として`useForm({defaultValues:<Reduxから取得したデフォルト値>})`のようにセットすると再度一から入力する必要がなくなる。 

の基準でReact Hook FormとuseState、Reduxの使い分けをするのがわかりやすくて不便な場面も少なくなりそうな方針かなと感じています。

## なぜReact Hook Formを使うのか

そもそも何故React Hook Formを使うのかということなんですが、ここが一番理解しておかないといけない点だと思うんですが、レンダーの回数を減らせるからです。
`useState`とかでテキストの入力を制御すると入力の状態が更新されるたびにレンダーされるので描画するのに時間のかかるページだと割と遅くなります。(正直、PCとかスマホ買い換えてからそこまで実感したことはありませんが)
なので、このメリットを活かせる場面では積極的にReact Hook Formを使用すると良いと思います。

## React Hook Form V7の良いところ

V６と比べてTypeScriptとの親和性がめちゃくちゃ高いです。
ホームページからそのままコードを拝借してきます。

```typescript
import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";

type Inputs = {
  example: string,
  exampleRequired: string,
};

export default function App() {
  const { register, handleSubmit, watch, formState: { errors } } = useForm<Inputs>();
  const onSubmit: SubmitHandler<Inputs> = data => console.log(data);

  console.log(watch("example")) // watch input value by passing the name of it

  return (
    /* "handleSubmit" will validate your inputs before invoking "onSubmit" */
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* register your input into the hook by invoking the "register" function */}
      <input defaultValue="test" {...register("example")} />
      
      {/* include validation with required or other standard HTML validation rules */}
      <input {...register("exampleRequired", { required: true })} />
      {/* errors will return when field validation fails  */}
      {errors.exampleRequired && <span>This field is required</span>}
      
      <input type="submit" />
    </form>
  );
}
```

`useForm<Inputs>`のように宣言しているので`register`の引数には`Input`のキーしか渡せません。
さらに`Inputs`をネストさせてみます。

```typescript
type Inputs = {
  example: string,
  exampleRequired: string,
  exampleNested: {
    hello:string,
  },
};
```

のようになっていた場合、
`register('exampleNested.hello')`のように引数に`.`を使用してキーを繋げることができます。それも何と型チェックをしてくれるので`register('exampleNested.hallo')`のようにスペルミスをしていた場合でもコンパイル時にはエラーを発見してくれるので安心です。

また、今回は省略しますが、`useFieldArray`を使用した際もTypeScriptの恩恵がかなり受けられて便利です。

## 難しいところ

TypeScriptのサポートも充実して使いやすくなったとは思うんですが、`useState`やReduxなどによる通常の状態管理と併用するときにどこまでをReact Hook Formに任せれば良いのか判断するのが難しいと思っています。
例えば、ボタンをクリックするとパネルが開いてパネルの中には入力フォームがあるというような画面を考えてください。
サンプルコードを置いておきます。

```typescript
import React, { useState } from 'react';
import { SubmitHandler, useForm, UseFormReturn } from 'react-hook-form';

interface FormInput{
  firstName: string,
  lastName: string,
  phoneNumber:number,
}

export default function App() {
  const [isPanelOpen, setIsPanelOpen] = useState(false);
  const formMethods = useForm<FormInput>();
  const { register, handleSubmit } = formMethods;
  const onSubmit: SubmitHandler<FormInput> = (input) => {
    console.log(input);
  };
  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input type="text" {...register('firstName')} />
        <input type="text" {...register('lastName')} />
        <button
          type="button"
          onClick={() => { setIsPanelOpen((prev) => !prev); }}
        >
          追加で入力する。
        </button>
        {isPanelOpen && <AdditionalForm formMethods={formMethods} />}
        <input type="submit" />
      </form>

    </div>
  );
}

interface AdditionalFormProps{
  formMethods:UseFormReturn<FormInput>
 }

function AdditionalForm({ formMethods }: AdditionalFormProps) {
  const { register } = formMethods;
  return (
    <div>
      <input type="number" {...register('phoneNumber')} />
    </div>
  );
}
```

上のコンポーネントでは「追加で入力する。」ボタンを押すと電話番号を入力する入力欄が表示されますが、再度「追加で入力する。」ボタンを押すとその入力欄は消えてしまいます。
しかし、入力欄が消えても以前に入力した内容は消えていないので送信ボタンが押されてしまうと電話番号のデータも送られてしまいます。（電話番号の入力欄が表示されていない場合は`undefined`にしたいかもしれないのに）

このように通常の状態管理とReact Hook Formが絡むと面倒になる印象です。
簡単に考えられる場合は以下のように入力のデフォルト値をAPIで非同期的に取得する場合です。

```typescript
const [defaultValues,setDefaultValues] = useState<FormInput>();
const fetchDefaultValues = async ()=>{
  const fetchedValues = await fetchData();
  setDefaultValues(fetchedValues);
}
useEffect(()=>{
  fetchDefaultValues();
},[])
```

そのような場合は以下のように`useForm()`関数の返り値に`reset`関数があるのでデフォルト値を設定することができます。


```typescript
export default function App() {
  const formMethods = useForm<FormInput>();
  const {
    register, handleSubmit,
    reset,
  } = formMethods;

  // 5秒後にデフォルト値をセットする。
  const [defaultValues, setDefaultValues] = useState<FormInput>();
  useEffect(() => {
    window.setTimeout(() => {
      setDefaultValues({
        firstName: 'michael',
        lastName: 'nakazato',
      });
    }, 5000);
  }, []);
  // デフォルト値が更新されたらフォームに反映する。
  useEffect(() => {
    reset(defaultValues);
  }, [defaultValues]);
  const onSubmit: SubmitHandler<FormInput> = (input) => {
    console.log(input);
  };
  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input type="text" {...register('firstName')} />
        <input type="text" {...register('lastName')} />
        <input type="submit" />
      </form>

    </div>
  );
}
```

以下の箇所でデフォルト値がセットされたのを検知してフォームに反映させています。

```typescript
  useEffect(() => {
    reset(defaultValues);
  }, [defaultValues]);
```

デフォルト値の場合でも`reset`関数を呼び出さないといけないのでひと手間掛かっていますが、先ほどのように通常の状態管理と絡む場合は常にReact Hook Formが管理していない状態についても考慮に入れないといけないのが難しいです。

例えば、以下のように送信時に実行される関数の中で`useState`で管理している状態の値も気にするとかしないといけない場面が多くなると思います。

```typescript
const onSubmit: SubmitHandler<FormInput> = (input) => {
  if(!isPanelOpen){
    console.log({
      firstName:input.firstName,
      lastName:input.lastName,
    })
    return;
  }
  console.log(input);
};
```

なので、入力に関係するものは全てReact Hook Formで管理するというのも一つの手だと思います。
それが以下のコードになります。このコード内ではパネルの開け閉めもReactHookFormで管理しています。

```typescript
import React, { useEffect, useState } from 'react';
import { SubmitHandler, useForm, UseFormReturn } from 'react-hook-form';
import { setTimeout } from 'timers/promises';

interface FormInput{
  firstName: string,
  lastName: string,
  phoneNumber: string,
  isPanelOpen: boolean,
}

export default function App() {
  const formMethods = useForm<FormInput>({
    defaultValues: {
      isPanelOpen: false,
    },
  });
  const {
    register,
    handleSubmit,
    reset,
    watch,
    setValue,
  } = formMethods;
  // ３秒後にデフォルト値を更新する。
  useEffect(() => {
    window.setTimeout(() => {
      reset({
        firstName: 'michael',
        lastName: 'nakazato',
        phoneNumber: '0120-111-111',
        isPanelOpen: false,
      });
    }, 3000);
  }, []);
　　　 // React Hook Formが管理しているisPanel属性を監視する。
  const isPanelOpen = watch('isPanelOpen');
  const onSubmit: SubmitHandler<FormInput> = (input) => {
    if (input.isPanelOpen) {
      console.log({
        firstName: input.firstName,
        lastName: input.lastName,
        phoneNumber: input.phoneNumber,
      });
    } else {
      console.log({
        firstName: input.firstName,
        lastName: input.lastName,
      });
    }
  };
  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input type="text" {...register('firstName')} />
        <input type="text" {...register('lastName')} />
        <button
          type="button"
          // isPanelOpenをトグルする。
          onClick={() => { setValue('isPanelOpen', !isPanelOpen); }}
        >
          追加で入力する。
        </button>
        {isPanelOpen && <AdditionalForm formMethods={formMethods} />}
        <input type="submit" />
      </form>

    </div>
  );
}

interface AdditionalFormProps{
  formMethods:UseFormReturn<FormInput>
 }

function AdditionalForm({ formMethods }: AdditionalFormProps) {
  const { register } = formMethods;
  return (
    <div>
      <input type="text" {...register('phoneNumber')} />
    </div>
  );
}
```

他にも入力欄をリストとして持っている場合には以下のようなコードを書いてしまうかもしれません。

```typescript
import React, { useEffect, useState } from 'react';
import { SubmitHandler, useForm, UseFormReturn } from 'react-hook-form';
import { setTimeout } from 'timers/promises';

interface FormInput{
  firstName: string,
  lastName: string,
  friends: {
    firstName: string,
    lastName: string,
  }[]
}

export default function App() {
  const formMethods = useForm<FormInput>();
  const {
    register,
    handleSubmit,
    reset,
    watch,
    setValue,
  } = formMethods;
  const onSubmit: SubmitHandler<FormInput> = (input) => {
    console.log({
      firstName: input.firstName,
      lastName: input.lastName,
    });
  };
  const [friends, setFriends] = useState<{
    firstName: string,
    lastName: string,
  }[]>([]);
  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input type="text" {...register('firstName')} />
        <input type="text" {...register('lastName')} />
        <h2>友達</h2>
        {
          friends.map((frined, friendIndex) => (
            <FriendForm
              // eslint-disable-next-line react/no-array-index-key
              key={friendIndex}
              formMethods={formMethods}
              friendIndex={friendIndex}
            />
          ))
        }
        <button
          type="button"
          onClick={() => {
            setFriends((prev) => [...prev, {
              firstName: '',
              lastName: '',
            }]);
          }}
        >
          友達を追加
        </button>
        <input type="submit" />
      </form>

    </div>
  );
}
interface FriendFormProps{
  friendIndex:number,
  formMethods:UseFormReturn<FormInput>
}

function FriendForm(props: FriendFormProps) {
  const { formMethods, friendIndex } = props;
  return (
    <>
      <input type="text" {...formMethods.register(`friends.${friendIndex}.firstName`)} />
      <input type="text" {...formMethods.register(`friends.${friendIndex}.lastName`)} />
    </>
  );
}
```

上のコードでは`useState`で友達についての情報を`friends`として管理しているのにも関わらず、`FriendForm`コンポーネントでは`friends`の値に関係なく、`formMethods`から直接入力を管理しています。
つまり、結局`friends`の情報は有効に管理できていないことになります。

なので、実際には以下のように`useFieldArray`を使うと良いでしょう。

```typescript
import React, { useEffect, useState } from 'react';
import {
  SubmitHandler, useFieldArray, useForm, UseFormReturn,
} from 'react-hook-form';

interface FormInput{
  firstName: string,
  lastName: string,
  friends: {
    firstName: string,
    lastName: string,
  }[]
}

export default function App() {
  const formMethods = useForm<FormInput>();
  const {
    register,
    handleSubmit,
    control,
  } = formMethods;
  const onSubmit: SubmitHandler<FormInput> = (input) => {
    console.log({ input });
  };
  const { fields: friends, append: appendFriend } = useFieldArray({
    control,
    name: 'friends',
  });
  return (
    <div>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input type="text" {...register('firstName')} />
        <input type="text" {...register('lastName')} />
        <h2>友達</h2>
        {
          friends.map((frined, friendIndex) => (
            <FriendForm
              // eslint-disable-next-line react/no-array-index-key
              key={friendIndex}
              formMethods={formMethods}
              friendIndex={friendIndex}
            />
          ))
        }
        <button
          type="button"
          onClick={() => {
            appendFriend({
              firstName: '',
              lastName: '',
            });
          }}
        >
          友達を追加
        </button>
        <input type="submit" />
      </form>

    </div>
  );
}
interface FriendFormProps{
  friendIndex:number,
  formMethods:UseFormReturn<FormInput>
}

function FriendForm(props: FriendFormProps) {
  const { formMethods, friendIndex } = props;
  return (
    <>
      <input type="text" {...formMethods.register(`friends.${friendIndex}.firstName`)} />
      <input type="text" {...formMethods.register(`friends.${friendIndex}.lastName`)} />
    </>
  );
}
```

また、複数の入力ページに跨ってフォームを入力して欲しい場面などは多々あると思いますが、その場合は確認ボタンが押されるなどそのページでの入力内容が確定したタイミングでReact Hook Formが管理している状態をReduxなどに保存してからページの移動を行うことで複数画面に渡るフォームを作成することができます。

## 結論

個人的にですが

- ボタン操作だけで済む画面なら`useState`で管理する。
- ボタンだけでなく、キーボードやフリック入力をしないといけない入力用のコンポーネントならボタンの状態も含めて全てReact Hook Formで管理する。
- 複数画面にまたがるフォームの場合はページごとに確認ボタン(**次へ**ボタンかもしれない)を設置して、確認ボタンが押された時にフォームの入力内容をReduxに送信する。全てのページの入力が済んだら全ての入力内容をまとめてAPIで送信する。
  - 戻るボタンが押された場合はReduxから前の画面のフォームで入力した内容を取得して、デフォルト値として`useForm({defaultValues:<Reduxから取得したデフォルト値>})`のようにセットすると再度一から入力する必要がなくなる。 

の基準でReact Hook FormとuseState、Reduxの使い分けをするのがわかりやすくて不便な場面も少なくなりそうな方針かなと感じています。