この記事を読むと幸せになれそうな人
Controlled Component (React.useState
や Redux を使う方法)で大きめのフォームを構築するとき、UIライブラリを使ったり、バリデーションを実装したりすると、ボイラープレートコードが多すぎて辟易してしまうと思います。
そんな人には、この React Final Form がオススメかもしれません。
続き物です
- #01『かんたんにフォームを構築する』
- #02『Render Props を使って、かんたんにバリデーションを実装する』
- #03『かんたんに2回クリックの防止とサーバー側バリデーションを実装する』 ←今ここ
1. 送信中にボタンを disabled
にする
送信ボタンを押した後、リクエストに対するレスポンスを待っている間にもう一度ボタンが押されてしまうと面倒なことになるので、レスポンスを待っている間は、ボタンを押せないようにする必要がありますよね。
FormRenderProps の submitting
プロパティは、handleSubmit
で渡したasync
関数が終了するまでの間、true
になります。これを使って、送信処理中にはボタンをdisabled
にし、ボタンの文言も "送信中..." に変更します。
setTimeout
を追加しているのは、alertのポップアップが表示されて、"OK"ボタンを押すまでの間、handleSubmit
が終了しないのを防ぐためです。
const ReactFinalFormPage: NextPage = () => {
type HandleSubmit = FormProps<ValuesType, ValuesType>["onSubmit"];
const handleSubmit: HandleSubmit = async (s) => {
await waitMs(1000);
+ setTimeout(() => {
window.alert(`JSON: ${JSON.stringify(s)}`);
+ }, 0)
};
return (
<Form
onSubmit={handleSubmit}
initialValues={initialValues}
- render={({ handleSubmit, valid}) => (
+ render={({ handleSubmit, valid, submitting}) => (
<form onSubmit={handleSubmit}>
<Field<string>
name="name"
validate={validateRequiredString}
render={({ input, meta }) => (
<div>
<input {...input} />
<ErrorMsg hidden={meta.touched} msgs={meta.error} />
</div>
)}
/>
<Field<string>
name="email"
validate={validateEmail}
render={({ input, meta }) => (
<div>
<input {...input} />
<ErrorMsg hidden={meta.touched} msgs={meta.error} />
</div>
)}
/>
- <button disabled={!valid}>送信</button>
+ <button disabled={!valid || submitting}>{submitting ? "送信中..." : "送信"}</button>
</form>
)}
/>
);
};
export default ReactFinalFormPage;
2. サーバー側バリデーションの追加
会員登録やログインの入力フォームでは、サーバーサイドでバリデーションが行われ、その結果を表示することがあります。
この例にあるように、 React Final Form には、サーバーサイドでのバリデーションに対応する機能も含まれています。
handleSubmit
関数の書き換え
const handleSubmit: HandleSubmit = async (s) => {
await waitMs(1000);
+ if(s.email === "taro@example.com") {
+ return {
+ email: ["このeメールアドレスはすでに使われています。"]
+ }
+ }
setTimeout(() => {
window.alert(`JSON: ${JSON.stringify(s)}`);
}, 0)
};
handleSubmit
関数において、 { フィールド名: エラー内容(any型) }
の形式のオブジェクト (ここでは、仮に Form Error Object と呼びます) を返すことで、サーバーから返ってきた(バリデーション結果や、パスワード不一致等の)エラーの内容をフォームに渡すことができます。(エラーは throw してはいけません。 throw するのは、通信に関するエラー等です。)
今回はサーバーを作っていないため、このようなダミー実装になります。
サーバーサイドバリデーションの結果の取り扱い
Form Render Props には、submitFailed
, dirtySinceLastSubmit
というプロパティがあるので、これを使ってフォーム全体のエラー状態を監視して、全体的なエラー表示とボタンの活・非活のをコントロールしてみましょう。
<Form
onSubmit={handleSubmit}
initialValues={initialValues}
render={({
handleSubmit,
valid,
submitting,
+ submitFailed,
+ dirtySinceLastSubmit,
}) => (
<form onSubmit={handleSubmit}>
{/* ... */}
+ <div>{submitFailed ? "登録に失敗しました。" : ""}</div>
- <button disabled={!valid || submitting}>{submitting ? "送信中..." : "送信"}</button>
+ <button disabled={!(valid || dirtySinceLastSubmit) || submitting}>
+ {submitting ? "送信中..." : "送信"}
+ </button>
+ </form>
)
submitFailed
プロパティ
submitFailed
プロパティは、handleSubmit
からエラーが返ってくると、true
になります。(この時、同時にvalid
プロパティが false
になります。) このプロパティが再び false
になるのは、もう一度 submit した時です。
submitFailed
が true
である時には、「登録に失敗しました」のメッセージを表示するようにしています。
dirtySinceLastSubmit
プロパティ
dirtySinceLastSubmit
プロパティは、エラーが返ってきたあと、フォームの状態に一項目でも差分があると true
になります。
なので、そのような状態になるとボタンが再びクリック可能になるようにしています。
3. サーバーサイドバリデーションの結果をフィールドに反映する
<Field<string>
name="email"
validate={validateEmail}
render={({ input, meta }) => (
<div>
<input {...input} />
<ErrorMsg hidden={meta.touched} msgs={meta.error} />
+ {!meta.dirtySinceLastSubmit &&
+ meta.submitError?.map((s: string) => (
+ <span key={s}>{s}</span>
+ ))}
</div>
)}
/>
meta.error
には、#01で実装した、クライアントサイドバリエーションの結果が入っています。
meta.submitError
には、それと同時に、そのフィールドに対するサーバーサイドバリデーションの結果として、 Form Error Object の、フィールド名に対応するプロパティ値が入ってきます。
{ email: ["このeメールアドレスはすでに使われています。"] }
というオブジェクトを handleSubmit
で返したときには、["このeメールアドレスはすでに使われています。"]
という値が入ってきます。
!meta.dirtySinceLastSubmit &&
という部分は、前述の FormState.dirtySinceLastSubmit
と同様に、そのフィールドの内容が、前回の submit 時点から差分が出たときに、エラー表示を抑制するために書いています。
(本当は ErrorMsg
コンポーネントを使いたいところなのですが、なぜかうまく動かないので長い記述になっています。)
コード全容
import { FieldValidator } from "final-form";
import { NextPage } from "next";
import { Field, Form, FormProps } from "react-final-form";
const waitMs = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
const initialValues = {
name: "aa",
email: "",
};
type ValuesType = typeof initialValues;
const validateRequiredString: FieldValidator<string> = (
value
): string[] | undefined => {
if (!value || value.length === 0) return ["必須要素です"];
return undefined;
};
const validateEmail: FieldValidator<string> = (value): string[] | undefined => {
if (!value || value.length === 0) return ["必須要素です"];
// from https://www.javadrive.jp/regex-basic/sample/index13.html#section1
const emailRegexp =
/^[a-zA-Z0-9_+-]+(.[a-zA-Z0-9_+-]+)*@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/;
if (!emailRegexp.test(value))
return ["Eメールアドレスのフォーマットに適していません"];
return undefined;
};
type ErrorMsgProps = { hidden?: boolean; msgs: string[] };
const ErrorMsg: React.FC<ErrorMsgProps> = ({ hidden, msgs }) => (
<span>{msgs && hidden && msgs.map((s) => <small key={s}>{s}</small>)}</span>
);
const ReactFinalFormPage: NextPage = () => {
type HandleSubmit = FormProps<ValuesType, ValuesType>["onSubmit"];
const handleSubmit: HandleSubmit = async (s) => {
await waitMs(1000);
if (s.email === "taro@example.com") {
return {
email: ["このeメールアドレスはすでに使われています。"],
};
}
setTimeout(() => {
window.alert(`JSON: ${JSON.stringify(s)}`);
}, 0);
};
return (
<Form
onSubmit={handleSubmit}
initialValues={initialValues}
render={({
handleSubmit,
valid,
submitting,
submitFailed,
dirtySinceLastSubmit,
}) => (
<form onSubmit={handleSubmit}>
<Field<string>
name="name"
validate={validateRequiredString}
render={({ input, meta }) => (
<div>
<input {...input} />
<ErrorMsg hidden={meta.touched} msgs={meta.error} />
</div>
)}
/>
<Field<string>
name="email"
validate={validateEmail}
render={({ input, meta }) => (
<div>
<input {...input} />
<ErrorMsg hidden={meta.touched} msgs={meta.error} />
{!meta.dirtySinceLastSubmit &&
meta.submitError?.map((s: string) => (
<span key={s}>{s}</span>
))}
</div>
)}
/>
<div>{submitFailed ? "登録に失敗しました。" : ""}</div>
<button disabled={!(valid || dirtySinceLastSubmit) || submitting}>
{submitting ? "送信中..." : "送信"}
</button>
</form>
)}
/>
);
};
export default ReactFinalFormPage;
(番外編) フォーム全体のエラーを表す特殊なキー
import { FORM_ERROR } from 'final-form'
の形で import できる、FORM_ERROR 文字列をキーにすることで、Form Error Object に、フォーム全体についてのエラーを含めることができます。
{ [FORM_ERROR]: ["このフォームは今のままではいけないと思っています。"] }
ログインフォームなどでは、セキュリティ上の理由でログイン失敗の詳細を表示したくないことがあると思いますが、そのような時に役立つ機能ですね。
See also:
Final Form と、そのReactバインディングである React Final Form のドキュメントです
https://final-form.org/docs/react-final-form/getting-started
この動画でのライブコーディングも、大いに参考になると思います。
今回の内容と関係のある、サーバーサイドバリデーションの結果の取り扱いのデモです。
https://final-form.org/docs/react-final-form/examples/submission-errors