react-hook-form v6.10 ~ 6.11.5 までで確認した挙動です。
やろうとしたこと
react-hook-formを使用して情報登録用のフォームを開発していたが、
仕様上フォームの入力内容バリデーションはAPIを呼び出してサーバーサイドで実施しなければいけなかった。
理由は単純で、フォームの入力内容がバリデーションを通った場合は入力項目を表示する確認画面を表示し、
バリデーションエラーがあった場合にはフォームの再入力を促すという画面構成だったからだった。
+------+
| 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を自作した。
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
のコールバックなどで手動でクリアすることになる。
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);