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

## はじめに
今回　React 19 で導入された[`useActionState`](https://ja.react.dev/reference/react/useActionState)を使ってフォーム機能を実装しました。
バリデーションライブラリは`yup`です。

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

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

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

## useActionState × yup によるフォーム実装
以前、[`React-Hook-Form`](https://www.react-hook-form.com/)×`yup`のフォームを実装していて、「`useActionState`で`React-Hook-Form`が不要になる？みたいな」話題に触れたことをきっかけに、今回`useActionState` × `yup`に置き換えた感じです。

以前の[`React-Hook-Form`×`yup`のフォームはこちら](https://codesandbox.io/p/sandbox/s2m3gz)に。

https://codesandbox.io/embed/7xsqvd?view=preview&module=%2Fsrc%2FContactMain.tsx

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

### `React-Hook-Form`と`useActionState`どっちがいい？
筆者は`React-Hook-Form`を軽く使った程度の者ですが以下のような所感を得ました。

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

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

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

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

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

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

### 具体的な実装例
#### フォーム
```tsx
"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 はクライアントコード内でしか呼び出せないことに注意してください。

- [フォームでサーバ関数を使用する](https://ja.react.dev/reference/rsc/use-server)

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

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

先のコードにおいて、`useActionState`に関する具体的な部分は以下になります。
```tsx
  // 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`については、以下の公式ドキュメントや、より詳細に書いて下さっている記事があるので本記事ではこの程度で留めておきます。

https://ja.react.dev/reference/react/useActionState

https://zenn.dev/uhyo/books/react-19-new/viewer/useactionstate

https://qiita.com/curry__30/items/24e7c144123a9fc15fa3

#### スキーマ
```tsx
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>;
```

特に普通。

#### サーバーアクション
```tsx
"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.",
    };
  }
};
```

[公式情報](https://ja.react.dev/reference/rsc/use-server)にあるように、`use server`ディレクティブによってサーバーアクションを明示しています。
そしてサーバー側での処理なので`async`が付いています。

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

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

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

#### クライアントバリデーションのカスタムフック
```tsx
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`カスタムフックで行っています。

```ts
const validationResult = await validateForm(formData);
```

- validateForm
`yup`スキーマを使用してフォーム入力をバリデーションする関数

```ts
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 ビルドしたところビルドエラーが出ました。

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

- types/node@22.14.0
- npm@11.2.0
:::

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

- 画像処理（最適化）？に関するエラー
- サイトマップに関するエラー
- サーバーアクションに関するエラー

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

[ビルドエラーに記載されたリンク](https://sharp.pixelplumbing.com/install)へ飛ぶと、`sharp`というライブラリのインストール説明ページでした。

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

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

https://nextjs.org/docs/messages/install-sharp

### サイトマップに関するエラー
`sitemap.ts`に`export const dynamic = "force-static"`を記述して、レンダリングモデルが SSG であることを明記する必要がありました。

- 表示されたエラーログ
```bash
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側にページでないことを通知）にするか、当該ディレクトリを削除してからビルドする必要がありました。

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

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

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