6
1

More than 1 year has passed since last update.

React Final Form #03 かんたんに2回クリックの防止とサーバー側バリデーションを実装する

Last updated at Posted at 2021-06-14

この記事を読むと幸せになれそうな人

Controlled Component (React.useState や Redux を使う方法)で大きめのフォームを構築するとき、UIライブラリを使ったり、バリデーションを実装したりすると、ボイラープレートコードが多すぎて辟易してしまうと思います。
そんな人には、この React Final Form がオススメかもしれません。

続き物です

1. 送信中にボタンを disabled にする

送信ボタンを押した後、リクエストに対するレスポンスを待っている間にもう一度ボタンが押されてしまうと面倒なことになるので、レスポンスを待っている間は、ボタンを押せないようにする必要がありますよね。

FormRenderPropssubmittingプロパティは、handleSubmitで渡したasync関数が終了するまでの間、trueになります。これを使って、送信処理中にはボタンをdisabledにし、ボタンの文言も "送信中..." に変更します。

setTimeoutを追加しているのは、alertのポップアップが表示されて、"OK"ボタンを押すまでの間、handleSubmitが終了しないのを防ぐためです。

pages/react-final-forms.tsx
  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関数の書き換え

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の部分
    <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 した時です。

submitFailedtrue である時には、「登録に失敗しました」のメッセージを表示するようにしています。

dirtySinceLastSubmitプロパティ

dirtySinceLastSubmit プロパティは、エラーが返ってきたあと、フォームの状態に一項目でも差分があると true になります。

なので、そのような状態になるとボタンが再びクリック可能になるようにしています。

3. サーバーサイドバリデーションの結果をフィールドに反映する

emailフィールドの状態
<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 コンポーネントを使いたいところなのですが、なぜかうまく動かないので長い記述になっています。)

コード全容
pages/react-final-forms/index.tsx
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://youtu.be/WoSzy-4mviQ

今回の内容と関係のある、サーバーサイドバリデーションの結果の取り扱いのデモです。

https://final-form.org/docs/react-final-form/examples/submission-errors

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1