0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

useActionState × yup によるフォーム実装と、Next.js 15 でのSSGビルドにおける注意事項

Last updated at Posted at 2025-03-29

はじめに

今回 React 19 で導入されたuseActionStateを使ってフォーム機能を実装しました。
バリデーションライブラリはyupです。

useActionStateに関しては、個人開発などで使ってみて何となく分かったという感触でした。
しかし、フォーム実装においては使ったことがなく、また(筆者が期待するような)具体的なサンプル例も少なかったので自分で作ってみようと思った次第です。

あと、おまけ?として、Next.js 15 にアップデートして SSG ビルドしたところ、ビルドエラーが出たためその対応も書いていきます。

まずは、フォーム実装についてです。

useActionState × yup によるフォーム実装

以前、React-Hook-Form×yupのフォームを実装していて、「useActionStateReact-Hook-Formが不要になる?みたいな」話題に触れたことをきっかけに、今回useActionState × yupに置き換えた感じです。

以前のReact-Hook-Form×yupのフォームはこちらに。

  • サンプル例について
    • バリデーションエラー発生時
      該当項目にテキストが表示されるとともに、Sandbox下部にあるConsole欄で詳細を確認できます
    • 処理成功(送信成功)時
      エラー発生時と同じくConsole欄で詳細を確認でき、フォームUI下部に入力内容を記載したアコーディオンが出てきます

React-Hook-FormuseActionStateどっちがいい?

筆者はReact-Hook-Formを軽く使った程度の者ですが以下のような所感を得ました。

  • React-Hook-Formを採用する動機
    非制御処理(再レンダリング無し) でシンプルにパパっとフォーム実装がしたい

  • useActionStateを採用する動機
    外部ライブラリへの依存を減らして React 純正でフォーム実装したいケースや、
    今後の React で主流になっていくであろうサーバーアクションなどサーバー側での処理実装に慣れておきたいケース

補足として、useActionStateは非制御処理のReact-Hook-Formとは違うので、送信アクション後にバリデーションを実施する場合は前回入力した内容を保持しません。

※ただし、この辺りは実装次第で如何ともなるような気がしています。
今回は軽い検証だったのであんまり踏み込んでおらず申し訳ないです。

上記のように、使用する状況や環境によって採用する動機が変わってくるかと思います。

あと、どちらにせよZodyupなどバリデーションライブラリは使ったほうが楽だと思います。

具体的な実装例

フォーム

"use client";

import { memo, useActionState, useRef } from "react";
import contactStyle from "./style/contactpage.module.css";
import { RegFormSchema } from "./schamas/regFormSchema";
import { useClientAsyncSubmitAction } from "./hooks/useClientAsyncSubmitAction";

export type useActionStateType = {
  isSubmitting: boolean;
  errorMessage?: string;
  errors?: Partial<RegFormSchema>;
  result?: Partial<RegFormSchema>;
};

function ContactMain() {
  const formRef = useRef<HTMLFormElement | null>(null);

  // useActionState の 初期 state を定義
  const initialState: useActionStateType = {
    isSubmitting: false,
  };

  // useActionState に設定する非同期関数
  const { clientAsyncSubmitAction } = useClientAsyncSubmitAction();

  const [state, formAction, isPending] = useActionState(
    clientAsyncSubmitAction,
    initialState
  );

  console.log(state);

  const onReset: () => void = () => {
    if (formRef.current !== null) {
      formRef.current.reset();
    }
  };

  return (
    <main className={contactStyle.contactWrapper}>
      <section>
        <h2 className={contactStyle.subsHeadLine02}>
          お問合せフォーム<span>Contact form</span>
        </h2>
        <form ref={formRef} action={formAction} id="mail_form">
          <dl>
            <div>
              <dt>メールアドレス</dt>
              <dd>
                <input
                  type="text"
                  id="mail"
                  /* FormDataで値を取得するためには、入力フィールドにname属性が必要 */
                  name="mail"
                />
                <p className={contactStyle.errorNotice}>{state.errors?.mail}</p>
              </dd>
            </div>
            <div>
              <dt>氏名</dt>
              <dd>
                <input
                  type="text"
                  id="name"
                  name="name"
                />
                <p className={contactStyle.errorNotice}>{state.errors?.name}</p>
              </dd>
            </div>
            <div>
              <dt>会社名</dt>
              <dd>
                <input
                  type="text"
                  id="companyName"
                  name="companyName"
                />
                <p className={contactStyle.errorNotice}>
                  {state.errors?.companyName}
                </p>
              </dd>
            </div>
            <div>
              <dt>郵便番号</dt>
              <dd>
                <input
                  type="text"
                  id="addressNumber"
                  name="addressNumber"
                />
                <p className={contactStyle.errorNotice}>
                  {state.errors?.addressNumber}
                </p>
              </dd>
              <dt>住所</dt>
              <dd>
                <input
                  type="text"
                  id="address"
                  name="address"
                />
                <p className={contactStyle.errorNotice}>
                  {state.errors?.address}
                </p>
              </dd>
            </div>
            <div>
              <dt>電話番号</dt>
              <dd>
                <input
                  type="tel"
                  id="tel"
                  name="tel"
                />
                <p className={contactStyle.errorNotice}>{state.errors?.tel}</p>
              </dd>
            </div>
            <div>
              <dt>お問い合わせ内容</dt>
              <dd>
                <textarea
                  id="content"
                  name="content"
                />
                <p className={contactStyle.errorNotice}>
                  {state.errors?.content}
                </p>
              </dd>
            </div>
            <div>
              <dt>入力内容のリセット</dt>
              <dd>
                <label className={contactStyle.resetLabel}>
                  <input type="button" onClick={onReset} />
                  入力内容をすべてリセットする
                </label>
              </dd>
            </div>
            <div>
              <dt>送信確認</dt>
              <dd>
                <input
                  type="checkbox"
                  id="isAgree"
                  name="isAgree"
                />
                <span className={contactStyle.errorNotice}>
                  {state.errors?.isAgree}
                </span>
              </dd>
            </div>
          </dl>
          <div id={contactStyle.form_submit}>
            <input
              disabled={isPending}
              type="submit"
              id={contactStyle.form_submit_button}
              value="送信する"
            />
          </div>
          {state.result && (
            <details style={{ marginTop: "5em" }}>
              <summary>{state.errorMessage}</summary>
              <ul>
                {state.result.mail && (
                  <li>メールアドレス:{state.result.mail}</li>
                )}
                {state.result.name && <li>氏名:{state.result.name}</li>}
                {state.result.companyName && (
                  <li>会社名:{state.result.companyName}</li>
                )}
                {(state.result.addressNumber || state.result.address) && (
                  <li>
                    郵便番号:{state.result.addressNumber} / 住所:
                    {state.result.address}
                  </li>
                )}
                {state.result.tel && <li>電話番号:{state.result.tel}</li>}
                {state.result.content && (
                  <li>お問い合わせ内容:{state.result.content}</li>
                )}
              </ul>
            </details>
          )}
        </form>
      </section>
    </main>
  );
}

export default memo(ContactMain);

ほとんどのフックと同様に、useActionState はクライアントコード内でしか呼び出せないことに注意してください。

上記引用にあるように'use client';ディレクティブが必要になります。

あと、これはHTMLJavaScriptに関する内容ですが、FormDataで値を取得するためには各入力項目にname属性が必須となります。

先のコードにおいて、useActionStateに関する具体的な部分は以下になります。

  // useActionState に設定する非同期関数
  const { clientAsyncSubmitAction } = useClientAsyncSubmitAction();

  // state: useActionStateによって処理されるステート
  // formAction: アクション関数、
  // isPending: 処理中・終了をチェックする真偽値
  const [state, formAction, isPending] = useActionState(
    clientAsyncSubmitAction, // 非同期処理
    initialState             // ステートの初期値
  );
  ...
  ..
  .
  // form の action に useActionState のアクション関数をセット
  <form ref={formRef} action={formAction} id="mail_form">

useActionStateについては、以下の公式ドキュメントや、より詳細に書いて下さっている記事があるので本記事ではこの程度で留めておきます。

スキーマ

import * as yup from "yup";
import type { InferType } from "yup";

export const regFormSchema = yup.object({
  mail: yup.string().required("メールアドレスを入力してください"),
  name: yup.string().required("お名前を入力してください"),
  companyName: yup.string(),
  addressNumber: yup
    .string()
    .max(7)
    .matches(/\d{7}/, "7 桁の数字で入力してください"),
  address: yup.string().required("住所は必須項目です"),
  tel: yup
    .string()
    .matches(
      /^0[0-9]{1}-[0-9]{4}-[0-9]{4}$|0[789]0-[0-9]{4}-[0-9]{4}$/,
      "xxx-xxxx-xxxx または xx-xxxx-xxxx の形式で入力してください"
    ), // OR(|)が正しく機能するようにするには不要なスペースを入れない
  content: yup.string().required("問い合わせ内容は必須項目です"),
  isAgree: yup.boolean().oneOf([true], "チェックを入れて下さい").required(),
});

export type RegFormSchema = InferType<typeof regFormSchema>;

特に普通。

サーバーアクション

"use server";

export const serverAsyncSubmitAction = async (formData: FormData) => {
  const mail = formData.get("mail");
  const name = formData.get("name");
  const companyName = formData.get("companyName");
  const addressNumber = formData.get("addressNumber");
  const address = formData.get("address");
  const tel = formData.get("tel");
  const content = formData.get("content");
  const isAgree = formData.get("isAgree");

  if (mail && name && content && isAgree) {
    return {
      success: true,
      mail: mail,
      name: name,
      companyName: companyName ?? "",
      addressNumber: addressNumber ?? "",
      address: address ?? "",
      tel: tel ?? "",
      content: content,
      isAgree: isAgree,
    };
  } else {
    return {
      success: false,
      errorMessage: "server-action validation error occurred.",
    };
  }
};

公式情報にあるように、use serverディレクティブによってサーバーアクションを明示しています。
そしてサーバー側での処理なのでasyncが付いています。

特徴としてはuseActionStateによってformDataから直感的にフォーム項目の内容を扱えるようになったことだと思います。

const mail = formData.get("mail");

サーバー側での処理なので、ここで厳密なバリデーションを実施することで安全性が高まりますし、管理や保守もしやすくなりそうです。

クライアントバリデーションのカスタムフック

import { useActionStateType } from "../ContactMain";
import { serverAsyncSubmitAction } from "../server-action/asyncSubmitActions";
import { useValidateForm } from "./useValidateForm";

export const useClientAsyncSubmitAction = () => {
  const { validateForm } = useValidateForm();

  // クライアント側でのバリデーション
  const clientAsyncSubmitAction = async (
    prevState: useActionStateType,
    formData: FormData
  ) => {
    const validationResult = await validateForm(formData);

    // false が返ってきている場合(=クライアントバリデーションが失敗した場合)
    if (!validationResult.isValid) {
      return {
        ...prevState,
        errorMessage: "フォームの入力に誤りがあります。",
        errors: validationResult.errors ?? {},
      };
    }

    // バリデーション後にサーバーアクションを実行
    try {
      const result = await serverAsyncSubmitAction(formData);

      if (result.success) {
        // 成功処理: 実用時にはリダイレクトや必要ハンドリング処理を実装
        
        return {
          isSubmitting: true,
          errorMessage: "処理成功 | エラーはありません",
          errors: undefined,
          result: {
            mail: result.mail?.toString(),
            name: result.name?.toString(),
            companyName: result.companyName?.toString(),
            addressNumber: result.addressNumber?.toString(),
            address: result.address?.toString(),
            tel: result.tel?.toString(),
            content: result.content?.toString(),
            isAgree: result.isAgree?.toString() === "on",
          },
        };
      } else {
        return {
          ...prevState,
          errorMessage: result.errorMessage ?? "エラーが発生しました。",
        };
      }
    } catch (error: unknown) {
      return {
        ...prevState,
        errorMessage: "サーバー送信中にエラーが発生しました。",
      };
    }
  };

  return { clientAsyncSubmitAction };
};

ここではクライアント側のバリデーションを行って、その処理結果を返しています。
具体的なバリデーション処理は以下のvalidateFormカスタムフックで行っています。

const validationResult = await validateForm(formData);
  • validateForm
    yupスキーマを使用してフォーム入力をバリデーションする関数
import * as yup from "yup";
import { regFormSchema, RegFormSchema } from "../schamas/regFormSchema";

export const useValidateForm = () => {
  const validateForm: (formData: FormData) => Promise<{
    errors?: Partial<RegFormSchema>;
    isValid: boolean;
  }> = async (formData: FormData) => {
    try {
      const formValues = {
        mail: formData.get("mail")?.toString() ?? "",
        name: formData.get("name")?.toString() ?? "",
        companyName: formData.get("companyName")?.toString() ?? "",
        addressNumber: formData.get("addressNumber")?.toString() ?? "",
        address: formData.get("address")?.toString() ?? "",
        tel: formData.get("tel")?.toString() ?? "",
        content: formData.get("content")?.toString() ?? "",
        isAgree: formData.get("isAgree") === "on",
      };

      await regFormSchema.validate(formValues, { abortEarly: false });

      // バリデーションに問題ない場合は true を返す
      return { isValid: true };
    } catch (err: unknown) {
      // yup 準拠のエラーハンドリング処理エリア
      if (err instanceof yup.ValidationError) {
        let validationErrors: Partial<RegFormSchema> = {};
        for (const error of err.inner) {
          if (error.path) {
            validationErrors = {
              // 既存設定を取得
              ...validationErrors,
              
              // フォーム項目名プロパティに
              //(当該フォーム項目に準拠する yup の)バリデーションテキストを格納
              [error.path]: error.message
            };
          }
        }

        return {
          isValid: false,
          errors: validationErrors,
        };
      }

      // 汎用なエラーハンドリング処理エリア
      if (err instanceof Error) {
        console.error(`client validation error occurred. | ${err}`);
      }

      // どの条件にもヒットしなかった場合
      return {
        isValid: false,
        errors: undefined,
      };
    }
  };

  return { validateForm };
};

try-catch文でyupのバリデーション処理結果に応じた処理分岐を行っています。

catchの中では、こちらも型ガードで条件分岐してyup 準拠のエラーハンドリング処理エリア汎用なエラーハンドリング処理エリア、どこにも該当しなかった場合など用意しています。


先にも述べましたが、
個人的には、外部ライブラリへの依存を減らして React 純正でフォーム実装したいケースや、
今後の React で主流になっていくであろうサーバーアクションなどサーバー側での処理実装に慣れておきたいケース、という場合にuseActionStateを使ってみても良いかもと感じました。

シンプルさで言うと(個人的には)React-Hook-Formの方がとっつきやすかったです。

Next.js 15 でのSSGビルドにおける注意事項

Next.js 15 にアップデートして SSG ビルドしたところビルドエラーが出ました。

Mac m2環境でのみ発生した事案になります。
筆者の個人PC(Windows 11)では問題なくビルドできました。
nodenpmは双方とも以下のバージョンです。

  • types/node@22.14.0
  • npm@11.2.0

具体的には以下となります。

  • 画像処理(最適化)?に関するエラー
  • サイトマップに関するエラー
  • サーバーアクションに関するエラー

画像処理(最適化)?に関するエラー

いつも通りconfigファイルにレンダリングモデル SSG を設定してから、npm run buildすると画像処理(最適化)?に関するエラーが発生しました。

ビルドエラーに記載されたリンクへ飛ぶと、sharpというライブラリのインストール説明ページでした。

インストールしたところ当該エラーは発生しなくなりました。

原因は、以下の公式ドキュメントにあるように Next が画像最適化処理で使用しているsharpが、何らかの理由で(インストール)使用できなかったためだと思われます。

サイトマップに関するエラー

sitemap.tsexport const dynamic = "force-static"を記述して、レンダリングモデルが SSG であることを明記する必要がありました。

  • 表示されたエラーログ
Collecting page data  
..Error: export const dynamic = "force-static"/export const revalidate not configured on route "/sitemap.xml" with "output: export".
See more info here: https://nextjs.org/docs/advanced-features/static-html-export

サーバーアクションに関するエラー

当然ですが、サーバーアクションは SSG に対応していません。

サーバーアクションを使っているディレクトリに_を前置してルーティングを無効(Next側にページでないことを通知)にするか、当該ディレクトリを削除してからビルドする必要がありました。

さいごに

ここまで読んでいただき、ありがとうございました。

筆者の知識・スキル不足から誤った記載などもあるかもしれません。
その際はご教示いただけますと嬉しく思います!

ビルドエラーに関しては発生すると驚きと失望が強い(気がする)ので、当記事が誰かの役に立てますと幸いです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?