react-hook-formでサーバーサイドで行ったバリデーション結果がコントロール操作時にクリアされる

react-hook-form v6.10 ~ 6.11.5 までで確認した挙動です。

## やろうとしたこと

react-hook-formを使用して情報登録用のフォームを開発していたが、
仕様上フォームの入力内容バリデーションはAPIを呼び出してサーバーサイドで実施しなければいけなかった。

理由は単純で、フォームの入力内容がバリデーションを通った場合は入力項目を表示する確認画面を表示し、
バリデーションエラーがあった場合にはフォームの再入力を促すという画面構成だったからだった。

```plaintext

  +------+      
  | Form |<---+
  +------+    |
     | Submit |
     +--------+  フォームが submit されたらバリデーションAPIで検証。
     |           検証OKなら確認画面へ、バリデーションエラーがあった場合は
     |           バリデーションエラーメッセージを表示したフォーム画面を再表示する。
     v
+---------+
| Confirm |
+---------+
    | Submit  確認画面で submit されたら登録完了。
    |
    ~
```

react-hook-formの`useForm()`が返す`setError()`でサーバー側のエラーをセットできるので利用していたが、
エラーがあったコントロールを操作しようとすると対応するバリデーションエラーメッセージが消えてしまった。

バリデーションエラーメッセージはフォームの入力中に何度でも確認できるよう、
submit されない限り表示し続けるという要件だったので、入力を再開した時点でメッセージが消えるのは喜ばしい挙動ではなかった。

## 対応

「コントロールの入力が開始された時点で対応するバリデーションエラーをリセットするべきである」というのが
react-hook-formの考えの様だった: https://github.com/react-hook-form/react-hook-form/issues/1881

react-hook-formにはフロントエンドサイドでフォーム入力中にインタラクティブなバリデーションを行う機能があり、
APIはこの機能をベースに設計されている。

入力を再開してバリデーションエラーが無くなったらエラーメッセージは非表示にするという方針のようだ。

『サーバーでバリデーションした結果を表示し続けたい』というような時は、
バリデーションエラーメッセージを`setError()`などで管理しないで独自のstateとして管理するしかないようだったので
以下のような`errors`と`setError`に近いAPIを自作した。

```typescript
import { useCallback, useState } from "react"
import type { UseFormMethods } from "react-hook-form"

// react-hook-form の setError はジェネリクスに渡された型に
// Array, object が含まれていると setError の第1引数にあらゆる型を受け入れるようになっている。
// あらゆる型を受け入れると Typescript の型チェックが働かなくなる。
// 強制的に型チェックを有効にするために与えられた型のプロパティ名に対して 
// unknown を持つダミータイプを提供する事で setError の型推論を強制的に復活させる。
type ForwardPropertyName<T> = { [K in keyof T]: unknown };

type SetPersistError<T> = UseFormMethods<ForwardPropertyName<T>>["setError"];

type ErrorsType<T> = UseFormMethods<T>["errors"];

type UsePersistErrorResult<T> = {
  persistErrors: ErrorsType<T>;

  setPersistError: SetPersistError<T>;
};

/**
 * コンポーネントがアンマウントされるまではエラー情報を保持する
 * react-hook-form の{@code useForm}が返す{@code errors}, {@code setErrors}と
 * 同等のオブジェクトと関数を返す。
 *
 * react-hook-form v6 の{@code errors}はフォームコントロールが変化すると
 * 全てのエラー情報を消去してしまう。
 * バックエンドでバリデーションした結果を永続的に表示し続けたい場合は、独自のエラー管理を行わなければいけない。
 * この関数はコンポーネントがアンマウントされるまではエラー情報を保持する
 * {@code persistErrors}と{@code setPersistError}を返す。
 * この2つは{@code errors}と{@code setErrors}と同じように使用できる。
 *
 * See: https://github.com/react-hook-form/react-hook-form/issues/1881
 */
const usePersistError = <T>(): UsePersistErrorResult<T> => {
  const [persistError, setPersistError] = useState<ErrorsType<T>>({});

  const setPersistErrorTyped: SetPersistError<T> = useCallback(
    (name, error) => setPersistError((prev) => ({ ...prev, [name]: error })),
    [setPersistError]
  );

  return { persistErrors: persistError, setPersistError: setPersistErrorTyped };
};

export { usePersistError };
export type { SetPersistError, ErrorsType };
```

使い方は`usePersistError()`で`{persistErrors, setPersistError}`を取り出し、
サーバーサイドバリデーションの結果は`setPersistError()`を呼び出して追加する。
`persistErrors`のプロパティにセットされているバリデーションエラー情報を画面に表示するという形になった。

react-hook-formのドキュメントに記載されているサンプルコード元にすると以下のような使い方になる。

バリデーションエラーメッセージは`handleSubmit`のコールバックなどで手動でクリアすることになる。

[codesandbox](https://codesandbox.io/s/react-hook-form-validationschema-v6-ts-forked-54sqx?file=/src/index.tsx)

```typescript
import React from "react";
import ReactDOM from "react-dom";
import { useForm } from "react-hook-form";
import { usePersistError } from "./usePersistError";

import "./styles.css";

interface IFormInputs {
  firstName: string;
  lastName: string;
  age: string;
  website: string;
}

function App() {
  const { register, handleSubmit } = useForm<IFormInputs>();
  const { persistErrors, setPersistError } = usePersistError<IFormInputs>();

  const onSubmit = (data: IFormInputs) => {
    // Clear error
    setPersistError("firstName", {});
    setPersistError("lastName", {});
    setPersistError("age", {});
    setPersistError("website", {});

    alert(JSON.stringify(data));

    if (!data.firstName) {
      setPersistError("firstName", { message: "firstName is missing." });
    }
    if (!data.lastName) {
      setPersistError("lastName", { message: "lastName is missing." });
    }
    if (!data.age) {
      setPersistError("age", { message: "age is missing." });
    }
    if (!data.website) {
      setPersistError("website", { message: "website is missing." });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>First Name</label>
        <input type="text" name="firstName" ref={register} />
        {persistErrors.firstName?.message && (
          <p>{persistErrors.firstName.message}</p>
        )}
      </div>
      <div style={{ marginBottom: 10 }}>
        <label>Last Name</label>
        <input type="text" name="lastName" ref={register} />
        {persistErrors.lastName?.message && (
          <p>{persistErrors.lastName.message}</p>
        )}
      </div>
      <div>
        <label>Age</label>
        <input type="text" name="age" ref={register} />
        {persistErrors.age?.message && <p>{persistErrors.age.message}</p>}
      </div>
      <div>
        <label>Website</label>
        <input type="text" name="website" ref={register} />
        {persistErrors.website?.message && (
          <p>{persistErrors.website.message}</p>
        )}
      </div>
      <input type="submit" />
    </form>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

```